Compare commits

...
Author SHA1 Message Date
sorokin.oleg.edu b2c3e54001 Verify Windows contextual authoring and preserve postboot evidence 2026-09-11 09:04:30 -07:00
sorokin.oleg.edu ca53f574e5 Save reboot checkpoint with source layout WIP and native evidence 2026-09-11 06:42:46 -07:00
sorokin.oleg.edu c4bad4b8de Preserve complete OA scenario native history and reopen evidence 2026-09-11 06:26:18 -07:00
sorokin.oleg.edu d95b1ab178 Preserve published standalone CLI release records 2026-09-11 06:20:22 -07:00
sorokin.oleg.edu df6f595626 Stage explicit prerequisite sources inside behavior creation forms 2026-09-11 06:09:29 -07:00
sorokin.oleg.edu 07eb3cd12b Sync agent instructions from main 2026-09-11 15:55:38 +03:00
sorokin.oleg.edu 27df4a3945 Package standalone source CLI with verified runtime and notices 2026-09-11 05:48:05 -07:00
sorokin.oleg.edu 6a610616e7 Publish verified Windows authoring prerelease evidence 2026-09-11 05:43:28 -07:00
sorokin.oleg.edu 01b76a1e89 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:14:13 -07:00
sorokin.oleg.edu 2ec72d4241 Verify native behavior creation recovery and source authoring contracts 2026-09-11 04:21:56 -07:00
sorokin.oleg.edu 4bb4aaf3dc Add named behavior creation and verified textual authoring examples 2026-09-11 01:54:55 -07:00
sorokin.oleg.edu d114a271c5 Refresh scenario viewport measurement when its identity changes 2026-09-11 01:21:24 -07:00
sorokin.oleg.edu 3978bbe23c Keep scenario authoring visible and restore exact caller context 2026-09-10 12:03:17 -07:00
Codex 81668f94ed Seed optional history values from explicit constructors 2026-09-10 11:52:36 -07:00
sorokin.oleg.edu ee97fcb1e3 Record updated usability objective and integration evidence 2026-09-10 11:40:45 -07:00
sorokin.oleg.edu b98f135799 Add portable Linux packaging and verify distribution notices 2026-09-10 11:38:10 -07:00
sorokin.oleg.edu a70de55ecd Integrate nullable stored-history constructor correction 2026-09-10 11:25:59 -07:00
sorokin.oleg.edu 31b7dc3e15 Restore inline naming focus after recovery 2026-09-10 11:25:06 -07:00
sorokin.oleg.edu 057edddef8 Integrate process, scenario and typed history authoring checkpoint 2026-09-10 11:14:43 -07:00
Codex b6b76efc0d Fix nullable stored-history constructor switching
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-10 11:13:27 -07:00
sorokin.oleg.edu b24a97360f Record release evidence and coordinated integration work 2026-09-10 11:13:11 -07:00
sorokin.oleg.edu 7d1df4f369 Fix inline architecture naming and captured Escape cancellation
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-10 11:10:24 -07:00
Codex 01bb52a76d Improve behavior authoring and typed trace history (untested checkpoint)
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-10 11:07:07 -07:00
sorokin.oleg.edu 1797516f46 Add 'PROJECT_GOAL.md'
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-10 10:26:35 -07:00
Codex e2ecfea69b Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
2026-09-10 10:06:55 -07:00
Codex 4b6b5fc197 Complete source 6 engineering workflows and preserve release validation evidence 2026-09-06 21:49:04 -07:00
Codex 2b2d2bc18b Integrate release recovery, semantic review, coverage and Windows packaging 2026-09-06 16:31:55 -07:00
sorokin.oleg.edu c091f80e6e docs: record integrated review gates and native constructor evidence
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 08:22:58 +03:00
sorokin.oleg.edu 14b8e187ea fix(desktop): invalidate filtered catalog on changed hosted head 2026-09-06 08:15:31 +03:00
sorokin.oleg.edu 00ac6eb86c fix(desktop): identify stale hosted catalog results 2026-09-06 08:15:31 +03:00
sorokin.oleg.edu 843635ed26 Add exact semantic history resets and unordered member review 2026-09-06 08:09:44 +03:00
sorokin.oleg.edu 2c0f93839f fix(desktop): detach hosted destination for invalid local source 2026-09-06 08:07:47 +03:00
sorokin.oleg.edu 09b7ebddc4 Record hosted native recovery and destination review findings 2026-09-06 07:57:02 +03:00
sorokin.oleg.edu cf04ff1442 Document hosted desktop workflows and local save behavior 2026-09-06 07:49:02 +03:00
sorokin.oleg.edu 1a68ae0795 Review closed semantic constructor groups with retained typed drafts 2026-09-06 07:49:01 +03:00
sorokin.oleg.edu 0e47384532 Record native hosted conflict and process verification 2026-09-06 07:48:15 +03:00
sorokin.oleg.edu 28ebbbbc4c Allow review of explicitly mapped existing OA Legacy ports 2026-09-06 07:39:43 +03:00
sorokin.oleg.edu 4f5906b0ff Normalize constructor review members without losing local history 2026-09-06 07:39:43 +03:00
sorokin.oleg.edu f08507a789 Keep process contribution editors fully visible in compact windows 2026-09-06 07:29:57 +03:00
sorokin.oleg.edu 691ef26a01 Record verified hosted Windows trial and reproducible checks 2026-09-06 07:23:18 +03:00
sorokin.oleg.edu 5930f91ed6 Polish hosted request controls and record full desktop checks 2026-09-06 07:23:18 +03:00
sorokin.oleg.edu 1b80671dd0 Add optional hosted desktop editing with conditional source saves 2026-09-06 07:23:18 +03:00
sorokin.oleg.edu 49a2446f85 Record native scalar review and integrated constructor verification 2026-09-06 07:11:44 +03:00
sorokin.oleg.edu 67ed8e09be Review nested message kind groups with exact timer scope 2026-09-06 07:05:50 +03:00
sorokin.oleg.edu 1086e54ecc Record integrated semantics and native authoring verification 2026-09-06 06:57:20 +03:00
sorokin.oleg.edu f4cb486243 Record cross-platform behavior scroll identity verification 2026-09-06 06:55:50 +03:00
sorokin.oleg.edu 8c600087dc Bind behavior scroll state to its document and owner 2026-09-06 06:51:59 +03:00
sorokin.oleg.edu 1b0b072c4b Record process camera regression gates and native retest binaries 2026-09-06 06:45:03 +03:00
sorokin.oleg.edu cf337851ff Preserve process camera and viewport while opening authoring tools 2026-09-06 06:41:57 +03:00
sorokin.oleg.edu 767634aea5 Record scalar review correction checks and frozen platform binaries 2026-09-06 06:32:45 +03:00
sorokin.oleg.edu e7fa061e23 Fix scoped semantic drafts and dependent review controls 2026-09-06 06:27:25 +03:00
sorokin.oleg.edu d114a5d40c Record integrated semantic Windows and REST contract verification 2026-09-06 06:16:34 +03:00
sorokin.oleg.edu c6db9d41d6 Keep integrated HTTP regression modules rustfmt clean 2026-09-06 06:14:09 +03:00
sorokin.oleg.edu 332e46bb9e Integrate semantic review core source API and scalar desktop workflow 2026-09-06 06:10:14 +03:00
sorokin.oleg.edu 6ad3cb92df Clarify grouped constructor retirement scope 2026-09-06 06:08:43 +03:00
sorokin.oleg.edu 036276813c Add typed scalar semantic review to desktop workflow 2026-09-06 06:07:39 +03:00
sorokin.oleg.edu 6bb2583b4a Allow first review of equal constructor discriminators 2026-09-06 06:01:48 +03:00
sorokin.oleg.edu 1a07475cfc Record combined behavior checks and current native evidence 2026-09-06 05:53:45 +03:00
sorokin.oleg.edu d130013155 feat(server): expose typed semantic transition review 2026-09-06 05:46:51 +03:00
sorokin.oleg.edu 60d162c259 Merge commit '1a3164b' into codex/data-contract-integration 2026-09-06 05:45:58 +03:00
sorokin.oleg.edu ae1602ad7a Preserve scenario viewport when editing a diagram message 2026-09-06 05:43:56 +03:00
sorokin.oleg.edu 1a3164bf16 Record exchange continuation review fix and updated freeze
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 05:43:48 +03:00
sorokin.oleg.edu 8bc564b4b2 Fix exchange-only process continuation with explicit append path 2026-09-06 05:42:01 +03:00
sorokin.oleg.edu dfc14bcd1f Record direct process editing verification and native gates 2026-09-06 05:35:47 +03:00
sorokin.oleg.edu 29df024a7f Clarify applicable semantic review replay guarantees 2026-09-06 05:34:35 +03:00
sorokin.oleg.edu 7a1e12bc7c Add direct process and chain occurrence editing 2026-09-06 05:33:42 +03:00
sorokin.oleg.edu c6c07cc3a3 Improve scenario tool feedback and keep draft actions visible 2026-09-06 05:32:34 +03:00
sorokin.oleg.edu 02f99a9ac5 Specify source five typed semantic review contract 2026-09-06 05:29:55 +03:00
sorokin.oleg.edu c746ab63c9 test(server): expect schema five from current exports 2026-09-06 05:28:25 +03:00
sorokin.oleg.edu 346fb24692 Implement closed typed semantic review with immutable proposals 2026-09-06 05:20:54 +03:00
sorokin.oleg.edu a306d6b2a8 Plan direct process and chain editing milestone 2026-09-06 05:12:38 +03:00
sorokin.oleg.edu b2d713e774 Record native property browser acceptance and remaining runtime gates 2026-09-06 05:10:47 +03:00
sorokin.oleg.edu 1bf8ebca96 Record combined DATA scenario verification and frozen Windows artifact
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 05:04:32 +03:00
sorokin.oleg.edu dfd2e41259 Merge tested R6 data workflows into direct scenario workspace 2026-09-06 05:04:04 +03:00
sorokin.oleg.edu 05a84b217d Record final scenario save-scroll review and build evidence 2026-09-06 04:59:01 +03:00
sorokin.oleg.edu e7e37d485d Keep observed scenario scrolling current during async saves 2026-09-06 04:58:45 +03:00
sorokin.oleg.edu b35dede75f Browse message value properties by record with bounded pages 2026-09-06 04:56:41 +03:00
sorokin.oleg.edu 5fa9c905c8 Record verified scenario review fixes and fresh binary evidence 2026-09-06 04:54:22 +03:00
sorokin.oleg.edu 7a666076bf Fix scenario scroll cancellation, operand activations and picker cost 2026-09-06 04:53:23 +03:00
sorokin.oleg.edu ab960cbebb feat(source): persist typed semantic reviews in schema five 2026-09-06 04:46:41 +03:00
sorokin.oleg.edu f5467e50c5 Preserve authored source and reports through desktop migration 2026-09-06 04:43:41 +03:00
sorokin.oleg.edu 74989b6f25 Keep nested data field names visible in compact pickers 2026-09-06 04:40:08 +03:00
sorokin.oleg.edu 524dabb9c3 Record direct scenario checks and pending native acceptance 2026-09-06 04:33:36 +03:00
sorokin.oleg.edu d1d8f0bbed Add direct scenario gestures and atomic ordered message insertion 2026-09-06 04:33:13 +03:00
sorokin.oleg.edu 0edadeebdb Record R6 cross-platform tests and native data workflows 2026-09-06 04:29:29 +03:00
sorokin.oleg.edu ab02b1b255 Freeze closed typed R3 semantic review implementation contract 2026-09-06 04:25:28 +03:00
sorokin.oleg.edu 93e3137342 Plan direct scenario drawing and ordered insertion milestone 2026-09-06 04:14:22 +03:00
sorokin.oleg.edu ab661f2d33 Bound scenario value authoring to focused paged fields 2026-09-06 04:08:46 +03:00
sorokin.oleg.edu 15ed1ff3af Verify qualified scenario values through HTTP and record R6 evidence 2026-09-06 04:07:49 +03:00
sorokin.oleg.edu b403039d00 Add typed data contract editing and bounded dictionary views 2026-09-06 03:58:43 +03:00
sorokin.oleg.edu 7426c03f4e Explain R6 data authoring and record verification gaps 2026-09-06 03:58:12 +03:00
sorokin.oleg.edu 959b83c78f Specify R6 data contracts and verify coupled REST migration 2026-09-06 03:46:13 +03:00
sorokin.oleg.edu 0b58107097 Implement bounded typed data and identified interface contracts 2026-09-06 03:44:09 +03:00
sorokin.oleg.edu 1230ab8a3e Migrate frozen data contracts and authored source to schema 4 2026-09-06 03:39:02 +03:00
sorokin.oleg.edu c5276b1943 Record Windows and Linux direct state editing acceptance 2026-09-06 03:31:47 +03:00
sorokin.oleg.edu 9118218ac3 Reset state camera and tools only when navigation changes view 2026-09-06 03:24:01 +03:00
sorokin.oleg.edu 2bf4c5451a Freeze data contract integration scope and compatibility strategy 2026-09-06 03:17:33 +03:00
1251 changed files with 225858 additions and 2437 deletions
+53
View File
@@ -12,3 +12,56 @@
*.png binary
*.jpg binary
*.zip binary
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
harness/fixtures/release1-native/** -text
harness/fixtures/interface-operation-probe/** -text
harness/history/2026-09-06-ledgers-before-consolidation/** -text
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-native-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-reopen-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-native-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-reboot-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-layout-reboot-20260911/run-gates2.ps1 whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/scenario-native-local-20260911/** -text whitespace=cr-at-eol
harness/experiments/scenario-native-local-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/scenario-native-local-20260911/**/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
# Preserve checksum-bound font notices verbatim through Git round trips.
harness/experiments/wsl-capacity-20260911/** -text whitespace=cr-at-eol
harness/experiments/wsl-postboot-9e8r6t-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-postboot-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-verified-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-full-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
harness/experiments/contextual-source-recovery-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-machine-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-lifeline-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-lifeline-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-machine-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-reopen-20260911/launch-method.ps1 whitespace=-blank-at-eof,cr-at-eol
# Raw compact.exe output includes CR-CR-LF and incidental trailing spaces.
harness/experiments/wsl-capacity-20260911/*.log -whitespace
+1
View File
@@ -11,3 +11,4 @@
/model-store/
/.local/
.arcadia-save-*/
__pycache__/
+24
View File
@@ -0,0 +1,24 @@
## Cooperation through Gitea MCP
Use Gitea MCP to coordinate with other agents working on this project.
- Before starting or resuming work, read relevant issues, comments, pull requests, and project Wiki pages to understand current progress and decisions.
- Use issues and comments to communicate plans, identify who is handling work, ask questions, report blockers, and leave handoffs. Check existing work before creating duplicates.
- Identify yourself consistently in messages. Do not assume another agent has read a message or is currently running.
- Respect work already in progress. Coordinate overlapping changes and preserve other agents' edits.
- Create and update the common project Wiki with shared knowledge, architecture, setup instructions, and decisions that will help future work. Keep it accurate as the project changes.
- Record meaningful progress and results in Gitea so another agent can continue without access to your conversation. Include relevant commits, validation results, and next steps.
- Publish versioned Gitea releases at meaningful, working milestones so the user can see progress and try the application. Include runnable builds or appropriate distributable artifacts, concise release notes, launch instructions, known limitations, and what was verified. Mark unfinished versions as prereleases. Coordinate release ownership to avoid duplicates.
- Keep coordination concise and useful. Use the available MCP tools, and report access failures honestly.
Work autonomously within the user's requested scope and follow the project's existing conventions.
## Gitea and harness maintenance
Keep Gitea resources and files under `harness` directory organized, accurate, and useful for other agents.
* As work progresses, consolidate duplicated information, update outdated guidance, close resolved or superseded issues, and remove obsolete temporary files. Perform maintenance when useful without letting it displace project work.
* Preserve a concise, durable record of what was done, decisions and their reasons, validation results, failed approaches, and remaining work. Link relevant issues, pull requests, commits, and releases so future agents can distinguish completed work from work that still needs attention and avoid repeating unsuccessful attempts.
* Prefer closing, marking as superseded, or archiving historical material over deleting it. Keep current instructions clearly separated from historical records, and link superseded guidance to its replacement.
* Before removing information or artifacts, check whether they contain unique knowledge, are referenced elsewhere, or are still needed by another agent. Preserve useful findings in the appropriate shared record first, update affected references, and coordinate cleanup that overlaps active work.
* Keep indexes and entrypoint documents concise and current so agents can quickly find authoritative guidance and relevant history.
Generated
+16
View File
@@ -2139,10 +2139,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "oa-cli"
version = "0.2.0"
dependencies = [
"oa-core",
"serde",
"serde_json",
]
[[package]]
name = "oa-core"
version = "0.2.0"
dependencies = [
"libc",
"same-file",
"serde",
"serde_json",
@@ -2155,10 +2165,16 @@ dependencies = [
name = "oa-desktop"
version = "0.2.0"
dependencies = [
"axum",
"iced",
"iced_runtime",
"oa-core",
"oa-server",
"reqwest",
"rfd",
"serde",
"serde_json",
"tempfile",
"tokio",
]
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
resolver = "2"
[workspace.package]
+74
View File
@@ -0,0 +1,74 @@
# ARCADIA Studio — development-run summary
Latest correction: your review identified a misplaced priority in this run. Advanced counterpart review received attention while basic diagram editing remained awkward. The first attempted fix added large creation buttons; you rejected that too. Actual use of the installed Capella 7.1 has now informed a redesign around a compact contextual palette, in-place naming and renaming, relevant existing elements, explicit activity allocation and clear counterpart destinations. Implementation and native verification remain in progress; the downloadable preview described below predates these corrections. [Observed Capella behavior and design decisions](harness/release-capella-basic-editing-20260907.md).
Updated 7 September 2026. This summarizes the whole continuation run: work integrated from earlier branches, new engineering features, defects found through actual use, and verification on this machine. **The product has advanced substantially, but the complete Windows/Linux release has not yet been accepted.** The latest Windows preview is built, packaged and available for your inspection below.
## Objective and starting point
The objective is a usable Operational Analysis (OA) and System Analysis (SA) engineering application, with equivalent graphical, textual and REST workflows, maintained traceability, offline guidance, and working Windows/Linux distributions. The run treated domain semantics, interaction design, recovery, examples and verification as part of the product—not merely as supporting documentation.
The initial checkout lagged behind other branches. An ancestry, patch-equivalence and code audit found that direct process/scenario/state editing, data contracts, hosted integration and earlier semantic review were already combined in `codex/data-contract-integration`. Work continued from that baseline on `codex/release1-continuation`; duplicate preliminary changes were preserved rather than overwriting integrated work. Source6 work and its evidence were subsequently committed at `4b6b5fc`; source7 and later improvements remain a separately reviewed continuation. No remote publication is implied. [Branch integration](harness/release-branch-integration-20260906.md)
## Engineering features and editing workflows
The integrated foundation includes architecture views and ports; typed data, interfaces and values; direct scenario/process gestures; and persisted state-machine views with regions, pseudostates, transitions and maintained action references. The run extended and tested those workflows rather than replacing their shared model.
Source6 added first-class OA **Communication Means** between participants, with allocated interactions and explicit direction. OA→SA transition requires reviewed component-port choices; it does not silently invent responsibility or substitute a participant for a port. Scenario endpoint resolution handles reverse communication on unoriented channels and correlated Call/Reply behavior. Native testing also corrected transitions that left users on the wrong layer or diagram despite producing the right model.
Saved views gained exchange categories, hidden-edge choices, relation-kind selection, behavior coverage/context filters and exchange-name/item labels. Filters preserve semantic identities and stable port geometry. Traceability views explicitly support both layers. Advisory checks now locate incomplete behavior coverage, non-leaf work exchanges and mission/capability gaps without turning legitimate unfinished engineering work into structural corruption. [Coverage and implementation map](harness/release-current-coverage-20260906.md)
Semantic reconciliation became an editable, atomic **multi-source review**. Engineers can review scalar values, constructors, keyed collections, ordered members, child ownership, incident relationships and artifact lifecycle changes. Included rows compose through the shared planner; excluded invalid rows retain their raw input. Conflicting intentions are checked against the actual destination field. Artifact creation, movement, deletion and all seven metadata fields preserve distinctions such as absent versus empty text. Native tests exercised complete Apply/Save/Undo/Redo cycles, not just individual controls. [Artifact evidence](harness/release-artifacts-native-20260906.md), [attachment evidence](harness/release-attachments-native-20260906.md)
The optional hosted workflow continues to use the same core model and REST service, with conditional ETags, explicit publish/save, conflict handling and retained local drafts. Connection credentials remain in memory. Typed commands, modular source, diagnostics and OpenAPI were kept aligned as representations evolved. Existing hosted acceptance is recorded separately from still-required current-platform transport cases. [Hosted workflow](harness/release-hosted-desktop.md)
## Source, history, recovery and onboarding
Home now offers New/Open, examples, the guide, recent local models and recoverable sessions. Missing recent files remain visible with a recovery route. The searchable offline engineering guide has related topics and guarded workspace actions; navigation and scroll behavior were corrected through minimum-window testing.
Periodic user-local checkpoints preserve the accepted source bundle and raw source, property, behavior, workflow and semantic-review drafts. They do not silently save model files or restore an executable command plan. Recovery creates an unsaved copy, rebuilds previews and requires explicit saving. Write preflight preserves the last good checkpoint if a new snapshot is invalid; close/write races and stale save receipts were covered with regressions.
Frozen, nonempty old5/old6 fixtures support bounded migration to source7. Accepted headers are upgraded while comments, includes and raw invalid edits are retained. Unsupported newer intent hidden in old structural fields or constructor caches is rejected without destroying the checkpoint; ordinary prose containing those words remains valid raw text. Native old5, old6 and current7 recovery cases compared full bundles, model exports and draft DTOs. [Recovery contract](harness/release-source7-recovery-20260906.md)
A real Save As defect was fixed: changing the entry filename had created an extra Undo transaction and cleared Redo. Save As now adopts file identity across retained history without an artificial model edit, with conservative filename-collision preflight before disk writes. Journal publication was hardened against Windows scanner/share races and accidental takeover of another writer; Linux uses no-replace publication with fail-closed unsupported behavior. These safeguards do not claim model-file compare-and-swap or universal power-loss guarantees. [Save As](harness/release-save-as-runtime-packaging.md), [journal review](harness/release-journal-publication-review-20260906.md)
A coherent water-pressure restoration lesson now connects OA responsibilities and communication to explicit SA ports, interfaces, processes, scenarios, state behavior, saved views and evidence. It contains 94 elements, 62 relations, five views, 68 traces and 30 source files, plus deterministic replay inputs and one deliberate warning. Home and the guide open it. Copy the **whole example folder**, including its evidence, before editing. [Walkthrough](examples/release1/README.md)
## Source7: two substantive semantic gaps closed
Independent probes established two gaps that existing constructs could not faithfully represent:
* **Caller-specific process assembly:** ContextualLink addresses include the caller and complete reference-occurrence path. The same leaf reused through two references remains two distinct contributions. Precedence, exchange assembly and common-work purposes have separate meanings; common work does not imply execution fusion. Deletion removes dependent links rather than silently shortening their addresses.
* **Exact interface-operation identity:** Messages can retain unresolved operation intent or select a specific InterfaceItemAllocation. Selection requires proof through actual compatible component ports. Reply derives its operation from the exact Call while validating its own channel and correlation; parameterless operations remain distinguishable. Removing an allocation never silently selects another equivalent-looking allocation, and ordinary channel validity still applies.
Both landed under one source/schema7 contract across core, parser/updater, REST, graphical forms, semantic review and recovery. Old versions reject new fields even when explicitly null. Reference/path admission and bounded contextual analysis are deterministic and independent of cache size; exhausted analysis reports incompleteness rather than fabricated reachability. [Ratified contract](harness/release-source7-integration-decision-20260906.md)
Native inspection then exposed obscured contextual routes, endpoint hit-priority conflicts, and a separate process Fit problem. Routing and qualified selection were corrected and independently checked. The latest Fit change measures the actual remaining viewport, adds bounded tools/details disclosures and keeps pending-form actions reachable. Its first six tests caught two real failures: Iced discarded zero-size slots and reset the canvas tree. A narrow slot-preserving fix passed all six unchanged expectations, the existing pending-scroll and branch-fit regressions, and the full desktop suite. [Fit review](harness/release-process-fit-independent-review-20260907.md)
## What has actually been verified
Windows checkpoints include core **433 passed/1 ignored**, corrected full server **56/56**, and now full desktop **570/570** after the Fit fix. The latest locked workspace binaries build, formatting and strict workspace/all-target Clippy all passed. Native checks used separately hashed binaries and independent complete-model/source oracles for artifacts, batch review, communication, teaching edits, source7 operation choices, nested links and crash recovery—including Save, Undo, Redo and reopening. These are scoped results, not one blanket final-release pass. [Current evidence ledger](harness/STATE.md), [source7 native report](harness/release-source7-native-acceptance-20260907.md)
Linux verification ran in a real Ubuntu guest under QEMU using Rust1.90, ext4 and immutable source snapshots. Current core completed **436 passed/2 ignored**. Server compilation passed; unit **2/2** and HTTP **50/51** passed. One timing-dependent test returned the legitimate 30-second deadline response504 instead of412, including an isolated rerun. Its replacement uses a private test-only preparation gate to prove real HTTP overlap and retain exact conflict/model/disk assertions; that correction passed on Windows and still needs Linux execution. [Linux gate](harness/release-source7-linux-gate-20260907.md)
WSL installation was authorized, but importing the isolated distro remains pending a Windows restart. QEMU resources, shutdown budgets and caches were preserved. A cross-built probe actually executed on Linux. After VMware clock adjustments interrupted one watchdog and another run paused for your Windows build, the **full Linux desktop cross-build passed** with Rust1.90 on a fresh snapshot containing the Fit fix. Its final cached build took2m20s; the ELF dependencies, glibc2.28 link baseline and all37 teaching files were checked. Actual Linux GUI execution remains unverified. The built snapshot precedes the subsequent guide-label wording update. [Cross-build status](harness/release-source7-linux-gui-cross-build-20260907.md)
The next Linux test session failed during kernel startup on three bounded attempts, before any corrected server tests ran. We preserved the failures and extracted the exact verified Ubuntu boot assets for a temporary timer-check workaround. Automatic approval review rejected its preparation/launch command as “blocked by policy,” so that experiment did not run. All task VM processes are stopped. [Boot evidence and pending gate](harness/release-source7-linux-server-corrected-gate-20260907.md)
## Latest preview and remaining release work
**Run the latest Windows preview:** open [arcadia-studio.exe](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm/arcadia-studio.exe). Keep its folder intact, including `libunwind.dll`; no compiler installation or PATH change is needed. To move it elsewhere, extract the whole [preview ZIP](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip). Home opens the guide and examples. This is a development-profile preview, not the final optimized release.
Or run this in PowerShell:
```powershell
& 'C:\Users\User\Documents\Git\ARCADIA-Studio\dist\ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm\arcadia-studio.exe'
```
The package contains 565 files, including all 37 teaching files; independent integrity verification and source/build provenance are recorded alongside the ZIP. The packaged executable's SHA-256 begins `DC50C887`; the ZIP's is `1fac10b30daf2f8e1dac29e9469485e4bea142e77ff5a13e4c9d1e41ff03abb2`. [Provenance](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip.provenance.json)
The packaged app launched with a clean PATH. At **1444×932**, the accepted OA caller graph fitted at **68%** with all four cards visible. A **1102×932** width was also observed; this does **not** establish the 700-pixel minimum-height case. Your Escape stopped Computer Use before the final UI checks; no clean close is claimed. A subsequent read-only oracle confirmed the saved model and both source files were unchanged; this does not establish unobserved in-memory state. Broader native acceptance remains open.
The subsequent guide update names the actual Tools, Selection details, Restore panels and Reply operation controls. Formatting, independent label review and a fresh Windows workspace-binary build passed; the downloadable preview above retains its original, already-tested bytes.
Remaining work is to finish native Fit checks, complete outstanding engineering/hosted workflows, execute the corrected Linux server gate, verify actual Linux GUI behavior, and produce optimized, independently reviewed final packages including the updated guide. Export is explicitly **Architecture SVG**; faithful process/scenario/state SVG is deferred. Logical/physical architecture layers and simulation are outside this OA/SA release. The applicable Windows/Linux acceptance obligations remain open. [Release plan](harness/PLAN.md)
+655
View File
@@ -0,0 +1,655 @@
You are the principal investigator, chief systems engineer, product architect, and engineering orchestrator responsible for taking this project from its current state to a coherent, validated, working release.
Your responsibility is not merely to write software. Your responsibility is to solve the underlying problem end-to-end.
The project may involve research, domain understanding, methodology design, semantics, formal specification, product design, UX, architecture, experimentation, implementation, testing, documentation, and integration. Treat all of these as first-class work when they contribute to the objective.
Software is one means of realizing and testing the solution, not automatically the primary unit of progress.
## Primary objective
Deliver the product described by the repository, specification, requirements, references, current implementation, and my instructions.
Persist until the intended outcome is genuinely achieved.
Do not stop at:
- research,
- planning,
- a plausible conceptual design,
- scaffolding,
- a compiling implementation,
- a happy-path prototype,
- or an apparently polished UI.
The final result must be conceptually coherent, technically sound, usable, implemented, and verified end-to-end.
Bias toward action and completion.
Resolve discoverable questions yourself through:
- repository exploration,
- web and literature research,
- primary documentation,
- inspection of comparable products,
- experiments and prototypes,
- implementation,
- runtime observation,
- Computer Use,
- automated evaluation,
- and independent agent review.
Ask me only when a consequential decision depends on information that cannot reasonably be discovered, inferred, experimented with, or treated as a reversible assumption.
If an unresolved question does not block independent work, continue working.
For reversible choices, make a reasonable assumption, record it, test its consequences, and revise it if evidence contradicts it.
## Operating model
Use an adaptive orchestrator-worker workflow.
Do not force the project through a predetermined software-development sequence.
At any given time determine what type of work most reduces uncertainty or advances the final objective:
- research,
- problem decomposition,
- requirements analysis,
- domain modeling,
- hypothesis generation,
- alternative design exploration,
- formalization,
- UX investigation,
- experimentation,
- prototyping,
- architecture,
- implementation,
- testing,
- runtime observation,
- or review.
Choose the next action based on the state of the whole project rather than simply taking the next coding task.
## Task Ledger
Maintain a concise durable Task Ledger in `harness/TASK_LEDGER.md`, unless an equivalent artifact already exists.
It should distinguish:
### Goal and success criteria
What outcome must ultimately exist.
### Verified facts
Facts established from authoritative sources, repository evidence, experiments, or direct observation.
### Given facts
Requirements or constraints explicitly supplied by me.
### Assumptions
Things currently treated as true but not yet verified.
### Open questions
Important unknowns.
### Candidate hypotheses / designs
Competing explanations or approaches that remain plausible.
### Decisions
Choices that have been made, together with their rationale and supporting evidence.
### Current strategy
The current high-level route toward the goal.
Do not silently convert an assumption into a fact.
Do not silently convert the first plausible idea into a decision.
Update the Task Ledger when important knowledge changes.
## Progress Ledger
Maintain `harness/STATE.md` as the Progress Ledger.
At meaningful checkpoints record:
- current objective,
- completed work,
- evidence obtained,
- unresolved issues,
- active experiments or implementations,
- what remains,
- whether meaningful progress is being made,
- and the highest-value next actions.
Periodically ask:
1. Are we measurably closer to the actual end goal?
2. Are we solving the right problem?
3. Are we repeating an approach that is not working?
4. Has new evidence invalidated part of the plan?
5. Are important unknowns being hidden underneath implementation work?
6. Would research, experimentation, redesign, or implementation produce the highest information gain now?
If progress has stalled or the plan rests on invalid assumptions, replan instead of repeatedly patching the current approach.
## Durable decisions and evidence
Maintain `harness/DECISIONS.md` for important long-lived decisions.
For consequential decisions capture:
- the problem,
- viable alternatives,
- relevant evidence,
- important tradeoffs,
- selected approach,
- why it was selected,
- and what evidence would justify revisiting it.
Maintain research / evidence notes when substantial external investigation is performed. Prefer primary and authoritative sources.
Do not create documentation merely for ceremony. These artifacts exist to preserve reasoning across long runs and fresh agent contexts.
## Multi-agent orchestration
Explicitly delegate work whenever independent parallel effort can reduce elapsed time, increase breadth, produce independent criticism, or protect the root agent's context.
Normally use approximately 3–5 concurrent subagents for substantial parallel work.
Do not spawn agents merely to satisfy a role list.
Create roles dynamically according to the problem.
Possible roles include:
- domain researcher,
- literature / standards researcher,
- comparable-product investigator,
- user-workflow analyst,
- requirements analyst,
- systems / semantics architect,
- formalization or language-design specialist,
- UX / interaction designer,
- alternative-solution generator,
- adversarial critic,
- experimental investigator,
- software architect,
- implementation worker,
- runtime / Computer Use evaluator,
- correctness reviewer,
- integration reviewer.
Give every subagent:
- one bounded question or responsibility,
- relevant context,
- clear boundaries,
- evidence expectations,
- and an explicit output format.
Independent agents should explore genuinely different questions or approaches rather than duplicating one another.
Use fresh agents for independent criticism when practical.
Do not let several agents concurrently edit the same files. Partition implementation ownership or use isolated worktrees.
The root orchestrator owns synthesis, decisions, integration, and final responsibility.
## Research loop: start broad, then narrow
For important areas where the solution is not yet known:
1. Map the problem space before committing to a solution.
2. Delegate independent research tracks in parallel.
3. Prefer authoritative and primary sources.
4. Study relevant existing systems, standards, methods, research, and user experience.
5. Separate observations from interpretations.
6. Synthesize findings and identify contradictions, gaps, and unanswered questions.
7. Decide whether another focused research wave is justified.
8. Stop researching when additional information has low expected value relative to designing or testing a solution.
Research should lead to decisions, hypotheses, requirements, experiments, or implementation—not accumulate indefinitely.
## Candidate-generation and idea-tournament loop
For consequential, uncertain, difficult-to-reverse design problems, do not accept the first reasonable solution.
Use an idea-tournament process.
### Generate
Produce several materially different candidate solutions or hypotheses.
Aim for diversity rather than cosmetic variations of one idea.
### Explore
Where useful, assign independent agents to develop promising candidates in greater depth.
### Reflect
Have independent critics examine candidates for:
- correctness,
- hidden assumptions,
- internal consistency,
- usability,
- conceptual simplicity,
- extensibility,
- failure modes,
- edge cases,
- compatibility with project goals,
- and evidence.
Critics should actively attempt to falsify proposals rather than merely improve them.
### Rank
Compare candidates against explicit criteria derived from the actual project goals.
Prefer pairwise comparison or an explicit decision matrix when the decision is sufficiently important.
Do not rank based on eloquence.
### Evolve
Improve the strongest candidates.
Combine complementary ideas when a hybrid is genuinely better.
Introduce a new candidate if criticism reveals that the existing candidate set shares the same flawed assumption.
### Decide
Select an approach only when enough evidence exists to justify convergence.
Record the decision and why alternatives lost.
Do not run a heavyweight idea tournament for trivial, local, or easily reversible decisions.
## Experiment-first behavior
When an important assumption can be tested cheaply, test it instead of debating it indefinitely.
Create focused experiments, prototypes, mockups, benchmarks, sample models, formal examples, interaction trials, or throwaway implementations to reduce uncertainty.
An experiment should state:
- question,
- competing expectations,
- method,
- observable result,
- conclusion,
- and implications.
Do not confuse a prototype used to answer a question with production architecture.
Delete or isolate experimental code when it should not become part of the final product.
## Evaluator-driven improvement
Whenever practical, convert subjective progress into explicit evaluation.
Before optimizing a solution, determine how quality can be measured.
Possible evaluators include:
- formal invariants,
- consistency checks,
- parsers / validators,
- reference examples,
- round-trip tests,
- transformation tests,
- compatibility tests,
- usability scenarios,
- task-completion tests,
- latency / performance measurements,
- automated tests,
- visual comparison,
- LLM judges with explicit rubrics,
- and independent human-like review using Computer Use.
For important artifacts define evaluation criteria before repeatedly modifying them.
Generate or modify candidates, evaluate them, diagnose deficiencies, and retain improvements.
Do not optimize against an evaluator that is a poor proxy for the real objective.
## Requirements and specification
Treat requirements and specifications as living engineering artifacts, not immutable text and not merely documentation written after implementation.
When the project involves new semantics, methods, languages, protocols, information models, or user workflows, these are primary product artifacts.
Make them precise enough that independent implementations could reach compatible behavior.
Important concepts should have:
- unambiguous definitions,
- scope,
- relationships,
- invariants,
- valid and invalid examples,
- lifecycle / transformation rules where relevant,
- edge cases,
- and externally observable behavior.
Maintain traceability from important requirements and semantic decisions to their realization and verification where practical.
When implementation exposes ambiguity or contradiction in the specification, resolve the underlying specification problem rather than merely encoding arbitrary behavior.
## Product and user-centered reasoning
Continuously reason from the target user's actual work rather than from implementation convenience.
Identify important user goals, workflows, decisions, information needs, failure modes, and repetitive tasks.
When studying an existing product:
Do not merely clone features.
For each important capability determine:
1. What underlying user need does it satisfy?
2. What underlying domain concept does it represent?
3. What is powerful about the existing solution?
4. What causes friction or confusion?
5. Which limitations are accidental implementation / history artifacts?
6. What alternative interaction or semantic model could satisfy the need better?
7. How can the improvement be evaluated?
Preserve valuable domain knowledge without preserving legacy UX unnecessarily.
## Planning
Maintain a living execution plan in `harness/PLAN.md` unless an equivalent already exists.
The plan may contain research, specification, experimentation, design, implementation, integration, and evaluation milestones.
Prefer milestones that resolve important uncertainty or create observable useful capability.
Do not structure the entire plan around software modules unless that genuinely matches the problem.
Identify dependencies between conceptual decisions and implementation work.
Do not implement large amounts of downstream functionality on top of uncertain foundational assumptions without first testing those assumptions.
Revise the plan when evidence demands it.
## Engineering execution
When software implementation is the appropriate next action, act as a strong end-to-end engineering team.
Prefer the simplest architecture that cleanly satisfies known requirements.
Design for long-term maintainability and extensibility where those qualities are genuinely required, while avoiding speculative abstraction.
Reuse appropriate existing conventions.
Keep responsibilities and interfaces explicit.
Fix root causes rather than accumulating workarounds.
Avoid unrelated refactoring.
Delete obsolete scaffolding and abandoned approaches.
Treat warnings, crashes, serious runtime errors, corrupted state, and obvious interaction defects as evidence requiring investigation.
For an implementation milestone:
1. Define expected observable behavior.
2. Inspect relevant architecture and constraints.
3. Implement the smallest coherent solution.
4. Build it.
5. Run it.
6. Exercise real behavior.
7. Collect evidence.
8. Diagnose failures.
9. Fix root causes.
10. Run proportionate automated verification.
11. Perform real runtime / UI verification when user-facing behavior changed.
12. Update durable state.
13. Continue.
Do not declare functionality complete from code inspection alone.
## GUI and Computer Use
For GUI applications, visual behavior and interaction quality are part of correctness.
Use Computer Use and other available debugging capabilities against the actual running application.
After meaningful UI changes:
- launch the real application,
- exercise realistic workflows,
- enter representative data,
- inspect resulting states,
- resize or navigate where relevant,
- capture and inspect visual results,
- inspect logs / console / network information when useful,
- identify problems,
- fix them,
- relaunch,
- and verify the fix.
Actively examine:
- information hierarchy,
- workflow discoverability,
- cognitive load,
- unnecessary mode switching,
- consistency,
- navigation,
- interaction feedback,
- selection behavior,
- error recovery,
- loading / empty / failure states,
- accessibility,
- clipping,
- overlap,
- alignment,
- spacing,
- typography,
- sizing,
- responsiveness,
- focus states,
- disabled states,
- and unfinished-looking UI.
Do not equate technical functionality with usability.
## Independent review
Use independent reviewers throughout the project, but select reviewers according to the artifact being evaluated.
A conceptual or semantic design may need:
- domain review,
- consistency review,
- adversarial / falsification review,
- simplicity review,
- and user-workflow review.
A language may need:
- ambiguity review,
- grammar / round-trip review,
- human-editability review,
- AI-editability review,
- and version-control review.
An implementation may need:
- correctness review,
- architecture review,
- requirements review,
- runtime QA,
- and UI / UX review.
A final release should receive a cross-disciplinary review rather than only a code review.
Require concrete findings and evidence.
For material findings:
1. verify the finding,
2. determine whether the problem is conceptual, specification-level, architectural, implementation-level, or interaction-level,
3. fix it at the correct layer,
4. reevaluate affected downstream artifacts,
5. verify again.
## End-state evaluation
Evaluate outcomes rather than demanding one predetermined path.
At meaningful checkpoints ask whether the resulting state satisfies the actual objective.
Before final completion, run a fresh independent end-state review covering at least:
1. Requirements completeness.
2. Domain / semantic coherence.
3. Internal consistency and edge cases.
4. Target-user workflows and usability.
5. Architecture and long-term maintainability.
6. Implementation correctness and regression risk.
7. Actual runtime behavior.
8. Documentation / specification adequacy.
9. Remaining assumptions, unknowns, and risks.
Resolve blocking and high-impact findings before declaring completion.
## Completion criteria
Do not weaken completion criteria merely because substantial time or context has been consumed.
The project is complete only when all applicable conditions are satisfied:
- The intended user problem is coherently addressed.
- Important domain concepts and semantics are explicit and internally consistent.
- Important design decisions are supported by evidence rather than accidental first choices.
- Major unresolved assumptions have either been validated, explicitly accepted, or isolated.
- Required specifications accurately describe implemented behavior.
- Primary user workflows are implemented.
- The application builds and launches reliably.
- Real workflows have been exercised on the running application.
- Important edge / failure / empty / loading states behave sensibly.
- Relevant automated evaluators and tests pass.
- UI/interaction quality has been inspected on the actual application.
- No known blocker or high-severity defects remain.
- Independent cross-disciplinary review has been completed.
- Valid material findings have been addressed and reverified.
- Architecture is understandable and appropriately extensible.
- Temporary and obsolete scaffolding has been removed.
- Durable project state reflects reality.
- Another competent engineer or agent could understand, build, run, evaluate, and continue the project from the repository.
If context becomes large, reload the durable ledgers and authoritative project artifacts rather than reconstructing history from conversational memory.
## Final handoff
Only when the completion criteria are satisfied, provide a concise handoff covering:
- what now exists,
- major discoveries,
- important conceptual / semantic decisions,
- important rejected alternatives,
- architecture,
- major implementation work,
- experiments and evaluations performed,
- real workflows exercised,
- independent reviews performed,
- remaining non-blocking limitations or uncertainties,
- and exact build / run / verification instructions.
Do not provide routine narration merely for the sake of narration.
Spend effort on solving, testing, evaluating, integrating, and improving the project.
## Model and reasoning allocation
The root orchestrator should use the highest available reasoning effort because its primary responsibility is high-leverage synthesis, planning, conceptual decisions, integration, and deciding what work should happen next.
Do not automatically use maximum reasoning for every delegated task.
Allocate reasoning effort according to the task:
* Use maximum reasoning for foundational semantic, methodological, formalization, metamodel, language-design, cross-domain synthesis, and other consequential hard-to-reverse decisions.
* Use extra-high reasoning for difficult architecture, UX / workflow design, alternative-solution evaluation, adversarial conceptual review, and unusually difficult implementation problems.
* Use high reasoning for normal implementation, correctness review, targeted research, Computer Use evaluation, debugging, and complex but bounded analysis.
* Use medium reasoning for code mapping, repository exploration, mechanical implementation, builds, routine tests, log inspection, and other well-defined work.
* Use lower effort only for genuinely trivial mechanical tasks.
Prefer spending additional reasoning on upstream decisions whose consequences propagate through many downstream artifacts.
Do not spend maximum reasoning merely because a task is long. Distinguish computational volume from conceptual difficulty.
When spawning a subagent, explicitly choose an appropriate reasoning effort when the interface permits it rather than blindly inheriting the root agent's effort.
Use GPT-6 Astra model exclusively.
## Application specification
### General description
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
Try to address all known problems / downsides of the original ARCADIA Capella software.
Version 1 MVP in the form of working Operational Analysis layer is done.
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
Remaining ARCADIA layers will be added in future releases.
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
Keep in mind that the target audience for the application are system engineers. And you should satisfy their needs and use cases
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
The application should support both forms of editing
The textual representation should be easily editable and understandble for both humans and AI
The textual representation should be easily handled by version control tools such as Git
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
The textual representation editor should have syntax highlighting and linting
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
@Computer
### Semantic model / metamodel
Requirements:
- Textual notation.
- Graphical notation.
- Constraints and calculations.
- Quantities and units.
- State and behavior modeling.
- Reusable model libraries.
- Strong, standardized specification.
- Strong, standardized API.
#### Textual notation
Requirements:
- Should not have visual noise and clutter.
- Should have means to reduce cognitive load when developing system models.
- Should be easily editable by humans. And AI agents.
- System engineers should be able to easily develop system models (both big and small) using textual notation only.
### UI
The focus should be on reducing system engineer's cognitive load when creating / working with a system model. Regardless of the model size (big or small).
Long, homogeneous lists in the UI that require user to scroll far or search for a desired option for too long - is a bad design.
## Active issues / current guidance
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
---
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
+64 -17
View File
@@ -2,14 +2,14 @@
Arcadia Studio is a native Rust/Iced modeling workbench for system engineers. V2 covers Operational Analysis (OA) and System Analysis (SA), with graphical and textual editing of one typed model and a deployable Rust REST service. It is an independent implementation inspired by Arcadia and Eclipse Capella, not a complete Capella or SysML implementation.
The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md), [validation](docs/validation.md) and [current state](harness/STATE.md) records document V2 tests and native Windows/Linux Computer Use verification.
The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md) and [validation](docs/validation.md) records document the earlier V2 baseline. [Current state](harness/STATE.md) and the [2026-09-06 coverage audit](harness/release-current-coverage-20260906.md) track subsequent implementation and outstanding release gates; the current first release is not yet accepted.
## Build and run
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. The standard Windows MSVC path uses Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
```sh
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
```
From the repository root:
@@ -18,14 +18,26 @@ From the repository root:
cargo run --locked -p oa-desktop --bin arcadia-studio
```
Build both desktop and server release executables:
Build the desktop, server and source CLI release executables:
```sh
cargo build --release --workspace --locked
```
Stage the complete teaching examples beside these executables so **Home → Open water-pressure walkthrough** works in an optimized source build. From the repository root, use `Copy-Item -Recurse -Force examples target/release/` in PowerShell or `cp -R examples target/release/` on Linux. If you set a different Cargo target directory or pass `--target`, use that build's executable directory instead of `target/release`. The packaging scripts include these examples automatically.
Run `target/release/arcadia-studio.exe` on Windows or `./target/release/arcadia-studio` on Linux. No task-specific cache directory is required. A graphical desktop and compatible graphics driver are required; the application uses Iced 0.14 with WGPU and X11/Wayland support. It does not require Java, Eclipse, a browser, an account or a running server.
An optional Windows path uses the `stable-x86_64-pc-windows-gnullvm` Rust toolchain with LLVM-MinGW. Put LLVM-MinGW's `bin` directory on `PATH`, including `clang.exe` and `llvm-windres.exe`, then run:
```powershell
rustup toolchain install stable-x86_64-pc-windows-gnullvm
$env:CARGO_TARGET_X86_64_PC_WINDOWS_GNULLVM_LINKER = 'clang.exe'
cargo +stable-x86_64-pc-windows-gnullvm build --release --workspace --locked
```
GNU/gnullvm builds require a resource compiler to embed the normal-user (`asInvoker`) manifest in executables and tests. The build selects `llvm-windres` for gnullvm or `windres` for GNU; set `RC` to its executable path if it is not on `PATH`. This avoids Windows treating installer-like test filenames as elevation requests. The current machine uses user-local Rust 1.98.1 and LLVM-MinGW 20260826 UCRT; compilation and test progress are recorded in [current state](harness/STATE.md). That evidence is separate from native workflow and release-package acceptance.
On the tested Ubuntu 24.04/WSLg environment, Mesa's Vulkan software adapter is required:
```sh
@@ -35,27 +47,50 @@ WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json
A compatible native Linux graphics adapter can use the ordinary command. This software-adapter workaround requires the indicated driver file. See the [platform verification record](docs/validation.md) for tested environments.
V2 release bundles are `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract the bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Both bundles include the headless server, examples, specifications and deployment instructions. Linux bundles target the documented Ubuntu 24.04 runtime. Packaged executable and archive evidence is recorded in [package verification](harness/verification-v2-packages.md).
The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract a verified bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Those bundles include the headless server, examples, specifications and deployment instructions; Linux targets the documented Ubuntu 24.04 runtime. [Package verification](harness/verification-v2-packages.md) applies to that recorded revision. Current changes still require fresh packages, checksums and acceptance.
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
## Work with source files from the command line
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
starting the desktop or server:
```sh
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
```
`stabilize ROOT` previews the identities needed for persistent editing;
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
`--write` to save that invocation's candidate, or `--json` for structured
diagnostics and changed-file text/diffs. Preview leaves files untouched.
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
## Model operational needs and system responsibilities
The coastal rescue example combines people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence. Choose **New** to start an empty project.
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
The example opens in an architecture view with work nested inside its performers. Each layer also has dedicated functional dataflow and mission/capability views. Regenerate both example formats with matching identities using `cargo run --locked -p oa-core --example generate_demo`; the command verifies both saved models. Historical migration fixtures remain separate.
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility.
The architecture diagram has a compact side palette; expand it to see tool names. Choose an element tool, click its position, enter its name in place and press **Enter**. **Escape** cancels the draft. Select a card and press **F2** to rename it. Right-click a participant and choose **Add activity** or **Add function** to create work with its performer allocation in one undoable edit. Ordinary palette creation leaves work unallocated. **Add existing** places a compatible model element in the current view; **Include already shown** offers **Locate** for existing placements.
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. **Communication means** connects operational Entities or Actors directly, with direction and carried items; **Exchange allocation** associates the Interactions it carries. Operational communication requires no system ports.
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values.
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. **View properties** stages saved line-kind, category, behavior-coverage, context, label and individual-hide choices. Right-click empty diagram space for **Duplicate view**, retaining the same semantic records in an independent presentation. Placement **Hide** removes a placement; line hiding retains its relationship; **Delete from model** removes semantics and dependent references.
5. In **Behavior & scenarios**, choose **New**, select Operational process (OA), Functional chain (SA), Scenario or State machine, then enter its name. For a scenario, choose what its lifelines represent; **Add optional context…** links a context without changing its layer-root ownership. For a state machine, search for its existing participant owner; optionally select **Create the first state view**. Choose **Create and open** to start authoring work contributions, lifelines, ordered occurrences or states. **Cancel · Esc** discards the creation draft; if another dialog is open, Escape dismisses that dialog first. Creation and its optional first view form one Undo operation. A new state machine starts without regions or states; **Create diagram** can later place its existing contents in a saved view. Behavior supports repeated exchanges, fragments/branches, availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. For an existing trace, **Review semantics…** compares typed content and prior observations, with explicit keep, take or final-value decisions for supported structural, scalar, constructor, individual-row and ordered-collection changes. Open additional trace rows and choose **Include this row** to combine reviewed source contributions into one validated Apply/Undo transaction; conflicting decisions require resolution. Review does not automatically synchronize the perspectives. **Attachments** reviews an existing child’s parent or a relationship endpoint. **Artifact links** reviews metadata, owner moves, explicit source-absence deletion and creation of a reserved counterpart after all introduced metadata is reviewed. These controls have automated coverage; their native acceptance remains pending. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
**Data & interfaces** separates exchange nature from mechanism, supports bounded reusable types and typed defaults, and gives each interface item allocation its own protocol and artifact identity. Operation parameters have provider-side directions; qualified message paths distinguish repeated Record uses. Record/item/interface specialization preserves inherited identities. See the [data contract](docs/language.rst) for exact semantics and compatibility.
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
Choose **Interaction**, **Communication means**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Communication means use OA participants directly. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
Create named **Exchange categories** at the perspective root to group Work exchanges or Participant exchanges. In **View properties**, each family defaults to All, including uncategorized exchanges. Selected categories combine by union; an explicit empty selection shows none of that family. Coverage and selected process/chain/scenario contexts intersect with category choices. Explicit hides survive changes to categories and labels. These settings change the saved presentation, not model relationships or node geometry. The [native record](harness/release-current-native-verification.md) covers the source-6 batch crash-recovery/Apply/Undo/Redo flow and a scoped forty-exchange view, including filtering, duplication, hiding and reopening. Communication authoring, remaining view combinations and latest attachment/artifact native acceptance remain pending.
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
@@ -67,10 +102,12 @@ In the state view, **Select / move** drags vertices and containers without repar
## Edit the same model as text
For a short text-only start, open [the distress-response model](examples/text-first/project.arcadia) and follow [its three-step walkthrough](examples/text-first/README.md). Four declarations and three explicit relationships describe participants, work, responsibility and an exchange; no diagram or handwritten UUID is needed.
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
```text
arcadia 3;
arcadia 7;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity respond "Respond to requests" {
@@ -100,9 +137,13 @@ Reparenting a nested element through Properties moves its declaration, descendan
## Save and reopen
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. Share or commit the entry file and all included source files together.
While work is unsaved, the desktop attempts a private recovery checkpoint every 10 seconds. It retains the accepted source bundle, raw source buffers, supported pending property/behavior forms and workflow drafts, including rejected input and semantic-review choices. **Home → Recover** opens an unsaved copy with no local or hosted save destination; use **Save as** to choose one. Undo/redo history is not restored. Checkpoints never write model files or save to the server, and credentials are excluded. A failed checkpoint is reported on Home; recent edits since the last completed checkpoint may be unavailable after a crash. Local history and checkpoints live under `%LOCALAPPDATA%/ArcadiaStudio` on Windows or `$XDG_DATA_HOME/arcadia-studio` (otherwise `~/.local/share/arcadia-studio`) on Linux. Explicit saves and version control remain the durable record.
The [current Windows native record](harness/release-current-native-verification.md) verifies scoped Home/recents workflows and forced-stop recovery of raw invalid source, invalid UUID properties, a pending timed transition, keyed semantic choices and a retained rejected order draft. Accepted repairs were saved and compared with independent source oracles, including exact Undo/Redo cases. It also records the older binary's transient journal-publication failure; the bounded fix has automated evidence in the [journal review](harness/release-journal-publication-review-20260906.md) and awaits new-binary native verification. Subsequent source-6 batch recovery and exact Apply/Save/Undo/Redo comparisons are recorded there too. These results do not accept all recovery variants, latest attachment/artifact workflows, Linux or the complete release.
## REST service and external tools
@@ -116,19 +157,25 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
```sh
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
-H 'Content-Type: application/json' --data-binary @system.json
```
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
## Development
Source7 integration adds caller-qualified reusable-process links and explicit interface-operation identity. Its [contract decision](harness/release-source7-integration-decision-20260906.md) and [current state](harness/STATE.md) distinguish prepared code from tested behavior. Current OpenAPI validation passes72structural fixtures across204schemas; source7 core, desktop, HTTP, recovery and native acceptance remain separate gates. Earlier source6 Windows and Linux results retain their exact recorded revision scope. Final full suites, strict checks, optimized/MSRV builds and current release packages are still required.
```sh
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
@@ -140,4 +187,4 @@ cargo build --release --workspace --locked
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges></security>
</trustInfo>
</assembly>
+1
View File
@@ -0,0 +1 @@
1 24 "windows.manifest"
+35
View File
@@ -0,0 +1,35 @@
// GNU Windows linkers need an explicit normal-user manifest, including tests
// named "updates", to avoid Windows installer detection. No elevation is needed.
fn main() {
println!("cargo:rerun-if-changed=../../build/windows_manifest.rs");
println!("cargo:rerun-if-changed=../../build/windows.manifest");
println!("cargo:rerun-if-changed=../../build/windows.rc");
println!("cargo:rerun-if-env-changed=RC");
let target = std::env::var("TARGET").unwrap_or_default();
if !target.ends_with("windows-gnu") && !target.ends_with("windows-gnullvm") {
return;
}
let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap())
.join("../../build");
let object = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap())
.join("normal-user-manifest.o");
let compiler = std::env::var_os("RC").unwrap_or_else(|| {
if target.ends_with("gnullvm") {
"llvm-windres"
} else {
"windres"
}
.into()
});
let result = std::process::Command::new(compiler)
.current_dir(root)
.args(["-i", "windows.rc", "-O", "coff", "-o"])
.arg(&object)
.status()
.expect("Windows GNU resource compiler is required (set RC if not on PATH)");
assert!(
result.success(),
"Cannot compile the normal-user Windows manifest"
);
println!("cargo:rustc-link-arg={}", object.display());
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "oa-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "arcadia"
path = "src/main.rs"
[dependencies]
oa-core = { path = "../oa-core" }
serde.workspace = true
serde_json.workspace = true
+451
View File
@@ -0,0 +1,451 @@
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
use oa_core::{
Command, language,
persistence::MigrationReport,
prepare_command,
source_files::{self, DiskProject, SourceFileError},
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
validation::Severity,
};
use serde::Serialize;
use std::path::{Path, PathBuf};
pub const LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1024,
model: language::ParseLimits {
max_source_bytes: 64 * 1024 * 1024,
max_graph_objects: 100_000,
max_records: 250_000,
max_step_references: 500_000,
},
};
#[derive(Debug, Clone)]
pub enum Operation {
Check,
Stabilize,
RenameAlias { old: String, new: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct Location {
pub path: String,
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
pub start: usize,
pub end: usize,
pub line: Option<usize>,
pub column: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
pub severity: String,
pub code: String,
pub message: String,
pub coordinate_space: String,
pub location: Option<Location>,
pub related: Vec<Location>,
pub object_id: Option<String>,
pub field: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Change {
pub path: String,
/// Exact captured and proposed UTF-8 text, including line endings.
pub before: String,
pub after: String,
pub diff: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub format_version: u32,
pub entry: String,
pub operation: String,
pub status: String,
pub exit_code: u8,
/// True only after a successful changed save. False on failure does not
/// promise rollback: consult the specific error/recovery outcome.
pub written: bool,
pub diagnostics: Vec<Diagnostic>,
pub changes: Vec<Change>,
pub identities_to_stabilize: usize,
pub migration: Option<MigrationReport>,
pub notes: Vec<String>,
}
impl Report {
pub fn failure(
entry: &Path,
operation: &str,
exit_code: u8,
code: &str,
message: String,
) -> Self {
let mut report = Self::empty(entry, operation);
report.fail(exit_code, code, message);
report
}
fn empty(entry: &Path, operation: &str) -> Self {
Self {
format_version: 1,
entry: entry.to_string_lossy().into_owned(),
operation: operation.into(),
status: "checked".into(),
exit_code: 0,
written: false,
diagnostics: vec![],
changes: vec![],
identities_to_stabilize: 0,
migration: None,
notes: vec![],
}
}
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
self.status = "rejected".into();
self.exit_code = exit_code;
self.diagnostics.push(Diagnostic {
severity: "error".into(),
code: code.into(),
message,
coordinate_space: "operation".into(),
location: None,
related: vec![],
object_id: None,
field: None,
});
}
}
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
let d = issue.diagnostic;
Diagnostic {
severity: match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
}
.into(),
code: d.code,
message: d.message,
coordinate_space: space.into(),
location: Some(Location {
path: issue.path,
start: d.span.start,
end: d.span.end,
line: Some(d.line),
column: Some(d.column),
}),
related: issue
.related
.into_iter()
.map(|at| Location {
path: at.path,
start: at.span.start,
end: at.span.end,
line: None,
column: None,
})
.collect(),
object_id: d.object_id.map(|id| id.to_string()),
field: d.field,
}
}
fn disk_error(report: &mut Report, error: SourceFileError) {
let code = match &error {
SourceFileError::Conflict(_) => "external_change",
SourceFileError::RecoveryRequired(_) => "recovery_required",
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
SourceFileError::Path(_) => "unsafe_path",
SourceFileError::Limit(_) => "source_limit",
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
SourceFileError::Invalid(_) => "invalid_save_candidate",
SourceFileError::Io { .. } => "io_error",
};
report.fail(3, code, error.to_string());
}
/// A candidate owns its original expectations and is consumed by saving.
/// Callers cannot replace its source/model/expectations after preparation.
pub struct Prepared {
report: Report,
snapshot: DiskProject,
compiled: CompiledSourceProject,
mutable: bool,
}
impl Prepared {
pub fn report(&self) -> &Report {
&self.report
}
pub fn into_report(self) -> Report {
self.report
}
pub fn write(mut self) -> Report {
if !self.mutable {
self.report
.fail(2, "usage", "check/lint does not accept --write".into());
return self.report;
}
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
Ok(_) => {
self.report.written = !self.report.changes.is_empty();
self.report.status = if self.report.written {
"written"
} else {
"unchanged"
}
.into();
}
Err(error) => disk_error(&mut self.report, error),
}
self.report
}
}
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
let name = match operation {
Operation::Check => "check",
Operation::Stabilize => "stabilize",
Operation::RenameAlias { .. } => "rename-alias",
};
let mut report = Report::empty(entry, name);
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
disk_error(&mut report, error);
Box::new(report.clone())
})?;
let raw = snapshot.reachable_bundle();
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
let mut diagnostics: Vec<_> = snapshot
.diagnostics
.iter()
.filter(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save"
| "source_encoding"
| "source_limit"
| "invalid_path"
| "missing_include"
| "project_limit"
)
})
.cloned()
.collect();
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
if !diagnostics.contains(&diagnostic) {
diagnostics.push(diagnostic);
}
}
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save" | "missing_include" | "invalid_path"
)
});
let invalid = diagnostics
.iter()
.any(|d| d.diagnostic.severity == Severity::Error);
report.diagnostics = diagnostics
.into_iter()
.map(|d| located(d, "input"))
.collect();
if invalid {
// Do not run declaration analysis or any updater on invalid raw text.
// Failed lint may also have allocated provisional object identities.
for diagnostic in &mut report.diagnostics {
diagnostic.object_id = None;
}
report.status = "invalid".into();
report.exit_code = if disk_refusal { 3 } else { 1 };
return Err(Box::new(report));
}
let explicit_ids: std::collections::BTreeSet<_> = raw
.files
.values()
.flat_map(|text| language::analyze(text).declarations)
.filter_map(|d| d.id.map(|id| id.to_string()))
.collect();
for diagnostic in &mut report.diagnostics {
if diagnostic
.object_id
.as_ref()
.is_some_and(|id| !explicit_ids.contains(id))
{
diagnostic.object_id = None;
}
}
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
report.identities_to_stabilize = raw
.files
.values()
.map(|text| {
language::analyze(text)
.declarations
.into_iter()
.filter(|d| d.id.is_none())
.count()
})
.sum();
report.migration = compiled.decoded.migration.clone();
let mutable = !matches!(operation, Operation::Check);
if let Operation::RenameAlias { old, new } = operation {
let project = &compiled.decoded.project;
let Some((id, _)) = project
.identities()
.into_iter()
.find(|(_, alias)| *alias == old)
else {
report.fail(
1,
"unknown_alias",
format!("No declaration has alias '{old}'."),
);
return Err(Box::new(report));
};
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
report.fail(1, "semantic_command_rejected", error.to_string());
Box::new(report.clone())
})?;
compiled = source_project::update_bundle_with_identity_map(
&compiled,
&command.project,
&command.identity_map,
LIMITS,
)
.map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
}
if mutable {
for (path, after) in &compiled.bundle.files {
let before = raw.files.get(path).cloned().unwrap_or_default();
if &before != after {
report.changes.push(Change {
path: path.clone(),
diff: unified_diff(path, &before, after),
before,
after: after.clone(),
});
}
}
report.status = "preview".into();
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
}
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
Ok(Prepared {
report,
snapshot,
compiled,
mutable,
})
}
/// One bounded hunk per file: common prefix/suffix plus three context lines.
/// Linear in input size; no quadratic LCS allocation for large modules.
fn unified_diff(path: &str, before: &str, after: &str) -> String {
let a: Vec<_> = before.split_inclusive('\n').collect();
let b: Vec<_> = after.split_inclusive('\n').collect();
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
let suffix = a[prefix..]
.iter()
.rev()
.zip(b[prefix..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
let start = prefix.saturating_sub(3);
let a_end = (a.len() - suffix + 3).min(a.len());
let b_end = (b.len() - suffix + 3).min(b.len());
let a_start = if a_end == start { start } else { start + 1 };
let b_start = if b_end == start { start } else { start + 1 };
let mut out = format!(
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
a_end - start,
b_end - start
);
let mut emit = |prefix: char, line: &str| {
out.push(prefix);
out.push_str(line);
if !line.ends_with('\n') {
out.push_str("\n\\ No newline at end of file\n");
}
};
for line in &a[start..prefix] {
emit(' ', line);
}
for line in &a[prefix..a.len() - suffix] {
emit('-', line);
}
for line in &b[prefix..b.len() - suffix] {
emit('+', line);
}
for line in &a[a.len() - suffix..a_end] {
emit(' ', line);
}
out
}
pub struct Arguments {
pub entry: PathBuf,
pub operation: Operation,
pub write: bool,
pub json: bool,
}
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
let mut positional = Vec::new();
let (mut write, mut json) = (false, false);
for arg in args {
if arg == "--write" && !write {
write = true;
} else if arg == "--json" && !json {
json = true;
} else if arg.to_string_lossy().starts_with('-') {
return Err(format!(
"Unknown or repeated option: {}",
arg.to_string_lossy()
));
} else {
positional.push(arg);
}
}
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
let operation = match (command, positional.len()) {
("check" | "lint", 2) if !write => Operation::Check,
("stabilize", 2) => Operation::Stabilize,
("rename-alias", 4) => Operation::RenameAlias {
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
},
_ => return Err(HELP.into()),
};
Ok(Arguments {
entry: PathBuf::from(positional[1]),
operation,
write,
json,
})
}
+102
View File
@@ -0,0 +1,102 @@
use oa_cli::{Report, arguments, prepare};
use std::{
io::{self, Write},
path::Path,
process::ExitCode,
};
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
let mut err = io::stderr().lock();
for d in &report.diagnostics {
let at = d
.location
.as_ref()
.map(|l| {
format!(
"{}:{}:{}",
l.path,
l.line.unwrap_or(0),
l.column.unwrap_or(0)
)
})
.unwrap_or_else(|| report.entry.clone());
writeln!(
err,
"{at}: {}[{}] ({}): {}",
d.severity, d.code, d.coordinate_space, d.message
)?;
}
writeln!(
err,
"{}: {} file changes; {} identities to stabilize",
report.status,
report.changes.len(),
report.identities_to_stabilize
)?;
if let Some(migration) = &report.migration {
writeln!(
err,
"Source migration: schema {} → {} (separate from identity stabilization)",
migration.from_schema, migration.to_schema
)?;
}
for note in &report.notes {
writeln!(err, "{note}")?;
}
if show_diff {
let mut out = io::stdout().lock();
for change in &report.changes {
write!(out, "{}", change.diff)?;
}
out.flush()?;
}
err.flush()
}
fn run() -> io::Result<u8> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
return Ok(0);
}
if args.len() == 1 && args[0] == "--version" {
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
return Ok(0);
}
let json = args.iter().any(|a| a == "--json");
let mut displayed = false;
let report = match arguments(&args) {
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
Ok(args) => match prepare(&args.entry, args.operation) {
Err(report) => *report,
Ok(prepared) => {
if args.write {
if !args.json {
human_report(prepared.report(), true)?;
displayed = true;
}
prepared.write()
} else {
prepared.into_report()
}
}
},
};
if json {
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
writeln!(io::stdout())?;
} else {
human_report(&report, !displayed)?;
}
Ok(report.exit_code)
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code),
Err(error) => {
let _ = writeln!(io::stderr(), "Output failed: {error}");
ExitCode::from(3)
}
}
}
+425
View File
@@ -0,0 +1,425 @@
use oa_cli::{LIMITS, Operation, Prepared, prepare};
use oa_core::{Id, Project, source_files, source_project};
use serde_json::Value;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
fs::create_dir(&path).unwrap();
Self(path)
}
fn put(&self, name: &str, text: &str) {
let path = self.0.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn entry(&self) -> PathBuf {
self.0.join("project.arcadia")
}
fn read(&self, name: &str) -> String {
fs::read_to_string(self.0.join(name)).unwrap()
}
fn prepare(&self, op: Operation) -> Prepared {
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
}
fn model(&self) -> Project {
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
.unwrap()
.decoded
.project
}
fn cli(&self, args: &[&str]) -> (i32, Value) {
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
command
.arg(args[0])
.arg(self.entry())
.args(&args[1..])
.arg("--json");
let output = command.output().unwrap();
assert!(
output.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
(
output.status.code().unwrap(),
serde_json::from_slice(&output.stdout).unwrap(),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
#[test]
fn complete_alarm_example_preview_then_write_and_fixed_point() {
let fixture = Fixture::new();
let source = include_str!(
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
);
fixture.put("project.arcadia", source);
let (code, check) = fixture.cli(&["check"]);
assert_eq!(code, 0);
assert_eq!(check["diagnostics"], serde_json::json!([]));
let (code, preview) = fixture.cli(&["stabilize"]);
assert_eq!(code, 0);
assert_eq!(preview["identities_to_stabilize"], 40);
assert_eq!(preview["status"], "preview");
assert_eq!(fixture.read("project.arcadia"), source);
assert!(
preview["changes"][0]["diff"]
.as_str()
.unwrap()
.contains("+project alarm_response")
);
let (code, written) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
let saved = fixture.read("project.arcadia");
assert_eq!(written["changes"][0]["after"], saved);
let project = fixture.model();
assert_eq!(project.identities().len(), 40);
let mut stripped = saved.clone();
for (id, _) in project.identities() {
stripped = stripped.replace(&format!(" id \"{id}\""), "");
}
assert_eq!(stripped, source, "only identity clauses may be inserted");
let (code, again) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
assert_eq!(again["changes"], serde_json::json!([]));
assert_eq!(again["status"], "unchanged");
assert_eq!(fixture.model(), project);
assert_eq!(fixture.read("project.arcadia"), saved);
}
#[test]
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let candidate = fixture.prepare(Operation::Stabilize);
let expected = candidate.report().changes[0].after.clone();
assert_eq!(candidate.report().identities_to_stabilize, 2);
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
assert_eq!(candidate.write().exit_code, 0);
assert_eq!(fixture.read("project.arcadia"), expected);
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
assert!(!expected.contains("description"));
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
let report = fixture.prepare(Operation::Stabilize).write();
assert_eq!(report.status, "unchanged");
assert!(!report.written);
assert_eq!(
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
metadata
);
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
}
#[test]
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
fixture.put(
"participants.arcadia",
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
);
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
.into_iter()
.map(|p| (p, fixture.read(p)))
.collect();
let team = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "team")
.unwrap()
.0;
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "rescue_team".into(),
});
assert_eq!(candidate.report().changes.len(), 2);
assert_eq!(fixture.model(), before, "preview cannot mutate");
assert_eq!(candidate.write().exit_code, 0);
let mut expected = before;
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
assert_eq!(fixture.model(), expected);
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
assert_eq!(
fixture.read("participants.arcadia"),
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
);
assert_eq!(
fixture.read("work.arcadia"),
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
);
}
#[test]
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
let fixture = Fixture::new();
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
fixture.put("project.arcadia", raw);
for command in [
vec!["check"],
vec!["stabilize", "--write"],
vec!["rename-alias", "a", "b", "--write"],
] {
let (code, report) = fixture.cli(&command);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
let diagnostics = report["diagnostics"].as_array().unwrap();
assert!(!diagnostics.is_empty());
for d in diagnostics {
assert_eq!(d["coordinate_space"], "input");
let at = &d["location"];
let start = at["start"].as_u64().unwrap() as usize;
assert!(raw.is_char_boundary(start));
assert_eq!(
at["line"].as_u64().unwrap() as usize,
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
);
let line = raw[..start]
.rsplit_once('\n')
.map_or(&raw[..start], |(_, tail)| tail);
assert_eq!(
at["column"].as_u64().unwrap() as usize,
line.chars().count() + 1
);
}
}
}
#[test]
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
let fixture = Fixture::new();
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
fixture.put("project.arcadia", raw);
let (code, result) = fixture.cli(&["lint"]);
assert_eq!(code, 0, "engineering warnings do not reject admission");
let warning = result["diagnostics"]
.as_array()
.unwrap()
.iter()
.find(|d| d["code"] == "work_without_behavior_context")
.unwrap();
assert_eq!(warning["severity"], "warning");
assert_eq!(
warning["location"]["start"],
raw.find("oa_activity").unwrap()
);
assert_eq!(warning["location"]["line"], 3);
assert_eq!(warning["location"]["column"], 5);
assert!(warning["object_id"].is_null());
assert_eq!(fixture.read("project.arcadia"), raw);
}
#[test]
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let pending = fixture.prepare(Operation::Stabilize);
let external = format!("{SIMPLE}// editor changed this\n");
fixture.put("project.arcadia", &external);
let result = pending.write();
assert_eq!(result.exit_code, 3);
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
assert_eq!(fixture.read("project.arcadia"), external);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
assert!(noop.report().changes.is_empty());
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
fixture.put("project.arcadia", &latest);
assert_eq!(noop.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), latest);
}
#[test]
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
let source = fixture.read("project.arcadia");
// An opaque incomplete journal must never be interpreted/recovered by CLI.
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
fs::create_dir(&journal).unwrap();
fs::write(journal.join("sentinel"), b"retain").unwrap();
for op in [Operation::Check, Operation::Stabilize] {
let report = prepare(&fixture.entry(), op).err().unwrap();
assert_eq!(report.exit_code, 3);
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
}
assert_eq!(
noop.write().exit_code,
3,
"saver must recheck after preview"
);
assert_eq!(fixture.read("project.arcadia"), source);
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
}
#[test]
fn rejected_alias_and_same_alias_preserve_source() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let source = fixture.read("project.arcadia");
for (old, new) in [
("team", "rescue"),
("team", "invalid alias"),
("absent", "valid"),
] {
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
assert_eq!(code, 1);
assert_eq!(result["status"], "rejected");
assert_eq!(fixture.read("project.arcadia"), source);
}
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
assert_eq!(code, 0);
assert_eq!(result["status"], "unchanged");
assert_eq!(fixture.read("project.arcadia"), source);
}
#[test]
fn rename_shows_missing_identity_and_separate_source_migration() {
let fixture = Fixture::new();
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
fixture.put("project.arcadia", old);
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "crew".into(),
});
let report = candidate.report();
assert_eq!(report.identities_to_stabilize, 2);
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
assert!(report.changes[0].diff.contains("-arcadia 5;"));
assert!(report.changes[0].diff.contains("+arcadia 7;"));
assert!(report.changes[0].after.contains("oa_entity crew"));
assert_eq!(fixture.read("project.arcadia"), old);
assert_eq!(candidate.write().exit_code, 0);
}
#[test]
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
.current_dir(&fixture.0)
.args(["check", "--json"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(error["diagnostics"][0]["code"], "usage");
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
let (code, _) = fixture.cli(&["check", "--write"]);
assert_eq!(code, 2);
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
}
#[test]
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
let fixture = Fixture::new();
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
fixture.put("project.arcadia", entry);
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 3, "a disk dependency could not be read");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "missing_include")
);
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1, "invalid encoding is invalid raw source");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "source_encoding")
);
assert_eq!(fixture.read("project.arcadia"), entry);
assert_eq!(
fs::read(fixture.0.join("part.arcadia")).unwrap(),
[0xff, 0xfe]
);
for raw in [
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
"arcadia 2; project p \"P\" {}",
] {
fixture.put("project.arcadia", raw);
let (code, _) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
}
}
#[test]
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
fixture.put(
"detached.arcadia",
"invalid detached draft must stay exact 雨",
);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let relation = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "responsible")
.unwrap()
.0;
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
assert_eq!(code, 0);
let mut expected = before;
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
assert_eq!(fixture.model(), expected);
assert!(
fixture
.read("project.arcadia")
.contains("@ // keep edge comment\nteam")
);
let captured = fixture.prepare(Operation::RenameAlias {
old: "p".into(),
new: "rescue".into(),
});
assert!(
captured
.report()
.changes
.iter()
.all(|c| c.path != "part.arcadia")
);
let root_before = fixture.read("project.arcadia");
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
assert_eq!(captured.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), root_before);
assert_eq!(
fixture.read("part.arcadia"),
"// external unchanged-dependency edit\n"
);
assert_eq!(
fixture.read("detached.arcadia"),
"invalid detached draft must stay exact 雨"
);
}
+3
View File
@@ -12,3 +12,6 @@ uuid.workspace = true
thiserror.workspace = true
tempfile = "3"
same-file = "1"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
+1
View File
@@ -0,0 +1 @@
include!("../../build/windows_manifest.rs");
@@ -0,0 +1,53 @@
//! Generate only the new teaching-project files; old compatibility examples stay untouched.
#[path = "../../../examples/release1/model.rs"]
mod teaching;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let directory = std::env::args_os()
.nth(1)
.map(std::path::PathBuf::from)
.unwrap_or_else(|| "examples/release1".into());
let lesson = teaching::build();
let bundle = teaching::checked_bundle(&lesson.project);
for (name, source) in &bundle.files {
let path = directory.join(name);
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, source)?;
}
for (name, value) in [
("model.json", serde_json::to_value(&lesson.project)?),
(
"transition-before.json",
serde_json::to_value(&lesson.before_transition)?,
),
(
"transition-request.json",
serde_json::to_value(&lesson.request)?,
),
(
"expected-diagnostics.json",
serde_json::to_value(oa_core::validation::validate(&lesson.project))?,
),
] {
std::fs::write(
directory.join(name),
serde_json::to_string_pretty(&value)? + "\n",
)?;
}
println!(
"Validated {} elements, {} relations, {} views, {} traces; wrote {} source files to {}",
lesson.project.elements.len(),
lesson.project.relations.len(),
lesson.project.diagrams.len(),
lesson.project.traces.len(),
bundle.files.len(),
directory.display()
);
for issue in oa_core::validation::validate(&lesson.project) {
println!(
"{:?} {} {:?}: {}",
issue.severity, issue.code, issue.element, issue.message
);
}
Ok(())
}
@@ -0,0 +1,442 @@
//! Deterministic dense-view fixture and semantic oracle. No GUI or authored routing.
//! cargo run -p oa-core --example release_dense_views -- OUTPUT_DIRECTORY
use oa_core::{view_options::*, *};
use serde_json::json;
use std::{
collections::{BTreeMap, BTreeSet},
error::Error,
fs,
path::PathBuf,
time::Instant,
};
fn id(n: u32) -> Id {
format!("d4060906-0000-4000-8000-{n:012x}").parse().unwrap()
}
fn element(p: &mut Project, n: u32, kind: Kind, alias: &str, name: &str, parent: Option<Id>) -> Id {
let identity = id(n);
assert!(
p.elements
.insert(
identity,
Element {
id: identity,
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body: ElementBody::new(kind, Layer::Oa).unwrap()
}
)
.is_none()
);
identity
}
fn relation(p: &mut Project, n: u32, alias: &str, name: &str, body: RelationBody) -> Id {
let identity = id(n);
assert!(
p.relations
.insert(
identity,
Relation {
id: identity,
alias: alias.into(),
name: name.into(),
description: String::new(),
body
}
)
.is_none()
);
identity
}
fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
let mut p = Project::empty();
p.id = id(1);
p.alias = "dense_service_views".into();
p.name = "Forty service exchanges".into();
p.description = "Deterministic release fixture. Forty actual work exchanges share two endpoints; no authored routes or synthetic endpoints.".into();
let requester = element(&mut p, 10, Kind::Entity, "requester", "Requester", None);
let provider = element(&mut p, 11, Kind::Entity, "provider", "Provider", None);
let a = element(
&mut p,
20,
Kind::Activity,
"request_service",
"Request service",
None,
);
let b = element(
&mut p,
21,
Kind::Activity,
"provide_service",
"Provide service",
None,
);
relation(
&mut p,
3000,
"requester_work",
"Requester responsibility",
RelationBody::Allocation {
source: requester,
target: a,
},
);
relation(
&mut p,
3001,
"provider_work",
"Provider responsibility",
RelationBody::Allocation {
source: provider,
target: b,
},
);
let group_names = ["Commands", "Status", "Alerts", "Measurements"];
let items: Vec<_> = group_names
.iter()
.enumerate()
.map(|(i, name)| {
element(
&mut p,
100 + i as u32,
Kind::ExchangeItem,
&format!("payload_{}", i + 1),
&format!("{name} payload"),
None,
)
})
.collect();
let exchanges: Vec<_> = (0..40)
.map(|i| {
relation(
&mut p,
2000 + i,
&format!("service_data_{:02}", i + 1),
&format!("Service data {:02}", i + 1),
RelationBody::Interaction {
source: a,
target: b,
items: vec![items[(i / 10) as usize]],
},
)
})
.collect();
let mut categories = vec![];
for (i, name) in group_names.iter().enumerate() {
let category = element(
&mut p,
200 + i as u32,
Kind::ExchangeCategory,
&format!("concern_{}", i + 1),
name,
None,
);
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() =
SharedBody::ExchangeCategory {
family: ExchangeFamily::Work,
exchanges: exchanges[i * 10..i * 10 + 10].to_vec(),
};
categories.push(category);
}
let overlapping = element(
&mut p,
204,
Kind::ExchangeCategory,
"critical_concern",
"Critical cross-cutting exchanges",
None,
);
*p.elements
.get_mut(&overlapping)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Work,
exchanges: [0, 10, 20, 30].map(|i| exchanges[i]).to_vec(),
};
categories.push(overlapping);
let mut contexts = vec![];
for (group, name) in group_names[..3].iter().enumerate() {
let process = element(
&mut p,
300 + group as u32,
Kind::Process,
&format!("process_{}", group + 1),
&format!("{name} process"),
None,
);
for offset in 0..10 {
let index = group * 10 + offset;
let occurrence = element(
&mut p,
1000 + index as u32,
Kind::Occurrence,
&format!("use_data_{:02}", index + 1),
&format!("Use service data {:02}", index + 1),
Some(process),
);
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchanges[index]),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&process)
.unwrap()
.steps_mut()
.unwrap()
.push(occurrence);
}
contexts.push(process);
}
let diagram = id(4000);
let nodes = [(a, 80.), (b, 760.)]
.into_iter()
.enumerate()
.map(|(i, (element, x))| {
let id = id(4100 + i as u32);
(
id,
NodePlacement {
id,
alias: format!("service_placement_{}", i + 1),
element,
position: Position { x, y: 160. },
size: Size {
width: 230.,
height: 150.,
},
collapsed: false,
port_anchors: vec![],
},
)
})
.collect();
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
(p, diagram, exchanges, categories, contexts)
}
fn projected(p: &Project, diagram: &Diagram) -> BTreeSet<Id> {
projection::project_diagram(p, diagram)
.edges
.into_iter()
.map(|edge| edge.id)
.collect()
}
fn overlap(a: routing::RoutingBounds, b: routing::RoutingBounds) -> bool {
a.position.x < b.position.x + b.size.width
&& b.position.x < a.position.x + a.size.width
&& a.position.y < b.position.y + b.size.height
&& b.position.y < a.position.y + a.size.height
}
fn main() -> Result<(), Box<dyn Error>> {
let output = PathBuf::from(
std::env::args_os()
.nth(1)
.ok_or("Supply an output directory for the dense-view fixture.")?,
);
let (p, diagram, exchanges, categories, contexts) = fixture();
let issues = validation::validate(&p);
assert!(
!issues
.iter()
.any(|issue| issue.severity == validation::Severity::Error),
"{issues:?}"
);
let pristine = p.clone();
let original = &p.diagrams[&diagram];
assert!(original.routes.is_empty());
let all = projection::project_view(&p, diagram);
assert_eq!(all.nodes.len(), 2);
assert_eq!(all.edges.len(), 40);
assert_eq!(
all.edges
.iter()
.map(|edge| (edge.source, edge.target))
.collect::<BTreeSet<_>>()
.len(),
1
);
let sets = |ids: &[Id]| ids.iter().copied().collect::<BTreeSet<_>>();
let cases = vec![
("all", ViewOptions::default(), sets(&exchanges)),
(
"first_category",
ViewOptions {
work_categories: CategorySelection::Any(vec![categories[0]]),
..ViewOptions::default()
},
sets(&exchanges[..10]),
),
(
"first_and_overlap",
ViewOptions {
work_categories: CategorySelection::Any(vec![categories[0], categories[4]]),
..ViewOptions::default()
},
exchanges[..10]
.iter()
.copied()
.chain([exchanges[10], exchanges[20], exchanges[30]])
.collect(),
),
(
"in_behavior",
ViewOptions {
coverage: Coverage::InBehavior,
..ViewOptions::default()
},
sets(&exchanges[..30]),
),
(
"outside_behavior",
ViewOptions {
coverage: Coverage::OutsideBehavior,
..ViewOptions::default()
},
sets(&exchanges[30..]),
),
(
"second_process",
ViewOptions {
work_contexts: ContextSelection::Any(vec![contexts[1]]),
..ViewOptions::default()
},
sets(&exchanges[10..20]),
),
(
"outside_second_process",
ViewOptions {
coverage: Coverage::OutsideBehavior,
work_contexts: ContextSelection::Any(vec![contexts[1]]),
..ViewOptions::default()
},
BTreeSet::new(),
),
(
"empty_categories",
ViewOptions {
work_categories: CategorySelection::Any(vec![]),
..ViewOptions::default()
},
BTreeSet::new(),
),
(
"empty_contexts",
ViewOptions {
work_contexts: ContextSelection::Any(vec![]),
..ViewOptions::default()
},
BTreeSet::new(),
),
(
"no_lines",
ViewOptions {
relation_kinds: RelationKinds::Only(vec![]),
..ViewOptions::default()
},
BTreeSet::new(),
),
(
"first_hide_05",
ViewOptions {
hidden_edges: vec![exchanges[4]],
work_categories: CategorySelection::Any(vec![categories[0]]),
exchange_labels: ExchangeLabels::NameAndItems,
..ViewOptions::default()
},
exchanges[..10]
.iter()
.filter(|edge| **edge != exchanges[4])
.copied()
.collect(),
),
];
let mut case_results = BTreeMap::new();
for (name, options, expected) in cases {
assert!(validate_options(&p, Layer::Oa, &options).is_empty());
let mut changed = original.clone();
changed.view_options = options;
assert_eq!(projected(&p, &changed), expected, "{name}");
let view = projection::project_diagram(&p, &changed);
assert_eq!(
view.nodes, all.nodes,
"{name}: visibility must not move nodes"
);
assert_eq!(
view.ports, all.ports,
"{name}: visibility must not move ports"
);
assert!(changed.routes.is_empty());
case_results.insert(name, json!({"count":expected.len(),"edges":expected}));
}
assert_eq!(
ExchangeLabels::Items.label(&p, exchanges[0]),
"Commands payload"
);
assert_eq!(
ExchangeLabels::NameAndItems.label(&p, exchanges[39]),
"Service data 40 · Measurements payload"
);
let began = Instant::now();
let routed = routing::route_diagram(&p, original, &all);
let route_ms = began.elapsed().as_millis();
assert_eq!(routed.edges.len(), 40);
let edges: Vec<_> = routed.edges.values().collect();
let mut label_overlaps = vec![];
let mut coincident_paths = vec![];
for (i, first) in edges.iter().enumerate() {
for second in &edges[i + 1..] {
if overlap(first.label.bounds, second.label.bounds) {
label_overlaps.push([first.id, second.id]);
}
if first.points == second.points {
coincident_paths.push([first.id, second.id]);
}
}
}
let source = language::format(&p);
assert_eq!(
language::parse(&source).expect("Canonical fixture source must reopen"),
p
);
let native_json = serde_json::to_vec_pretty(&p)?;
assert_eq!(persistence::decode(&native_json)?, p);
assert_eq!(
p, pristine,
"Oracles and routing must not mutate model facts"
);
let report = json!({"schema":p.schema_version,"project":p.id,"diagram":diagram,"semantic_oracles":"passed","elements":p.elements.len(),"relations":p.relations.len(),"cases":case_results,
"routing_ms":route_ms,"label_overlap_pairs":label_overlaps,"exactly_coincident_path_pairs":coincident_paths,
"routing_warnings":edges.iter().map(|edge| json!({"edge":edge.id,"warnings":edge.warnings.iter().map(ToString::to_string).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"native_readability":"pending; metrics do not establish a usability pass"});
fs::create_dir_all(&output)?;
fs::write(output.join("dense-40.arcadia"), source)?;
fs::write(output.join("dense-40.json"), native_json)?;
fs::write(
output.join("dense-40-oracle.json"),
serde_json::to_vec_pretty(&report)?,
)?;
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
@@ -507,6 +507,7 @@ fn workshop() -> Project {
let baseline = TraceBaseline {
oa: p.properties(t.oa).unwrap(),
sa: p.properties(t.sa).unwrap(),
semantic: None,
};
p.traces.get_mut(&id).unwrap().baseline = Some(baseline);
}
File diff suppressed because it is too large Load Diff
+27 -11
View File
@@ -15,6 +15,7 @@ fn occurrence(
let id = add(p, Kind::Occurrence, layer, name, Some(owner));
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender,
receiver,
@@ -34,10 +35,7 @@ fn occurrence(
id
}
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
let items = match p.elements[&interface].shared() {
Some(SharedBody::Interface { items }) => items.clone(),
_ => vec![],
};
let items = crate::data::interface_items(p, interface);
let contract = if p.elements[&owner].kind() == Kind::Function {
if direction == PortDirection::Out {
PortContract::FunctionOutput {
@@ -81,6 +79,7 @@ fn diagram(
description: "A focused projection of the shared engineering model.".into(),
layer,
projection,
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
@@ -114,6 +113,7 @@ fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
@@ -168,6 +168,11 @@ pub fn coastal_rescue() -> Project {
"Distress report",
None,
);
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&oa_item).unwrap().shared_mut()
{
*mechanism = ExchangeMechanism::Event;
}
p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
p.relate(
RelationKind::Allocation,
@@ -308,6 +313,11 @@ pub fn coastal_rescue() -> Project {
"Digital distress report",
None,
);
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&information).unwrap().shared_mut()
{
*mechanism = ExchangeMechanism::Event;
}
let material = add(
&mut p,
Kind::ExchangeItem,
@@ -317,6 +327,8 @@ pub fn coastal_rescue() -> Project {
);
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
let energy = add(
&mut p,
@@ -327,6 +339,8 @@ pub fn coastal_rescue() -> Project {
);
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
let field = add(
&mut p,
@@ -340,6 +354,9 @@ pub fn coastal_rescue() -> Project {
minimum: 1,
maximum: Some(1),
unit: "degrees north".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
let interface = add(
&mut p,
@@ -348,13 +365,12 @@ pub fn coastal_rescue() -> Project {
"Distress reporting interface",
None,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface {
items: vec![information],
};
p.relate(
RelationKind::InterfaceItemAllocation,
"Distress payload",
interface,
information,
);
let vessel_port = port(
&mut p,
actor,
+201 -20
View File
@@ -1,4 +1,6 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
#[path = "language/semantic_source.rs"]
mod semantic_source;
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
@@ -82,7 +84,20 @@ impl ParseLimits {
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.sum();
.sum::<usize>()
.saturating_add(
project
.relations
.values()
.map(|r| match &r.body {
RelationBody::ContextualLink { source, target, .. } => source
.references
.len()
.saturating_add(target.references.len()),
_ => 0,
})
.sum::<usize>(),
);
self.check_counts(graph, records, steps)
}
}
@@ -340,12 +355,21 @@ fn reference_field(key: &str) -> bool {
| "referenced"
| "local"
| "field"
| "path"
| "references"
| "occurrence"
| "allocation"
| "work"
| "supertype"
| "sender"
| "receiver"
| "action"
| "lifeline"
| "state"
| "constrained"
| "exchanges"
| "hidden_edges"
| "Any"
| "source"
| "target"
| "source_port"
@@ -378,10 +402,14 @@ fn enum_field(key: &str) -> bool {
| "message_kind"
| "transition_kind"
| "side"
| "mechanism"
| "storage"
| "transmission"
| "acquisition"
)
}
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never
/// adopts a model and never overwrites an editor draft.
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
parse_limited(source, ParseLimits::UNLIMITED)
@@ -546,7 +574,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
/// nested records are ordinary language fields; JSON documents are not embedded.
pub fn format(project: &Project) -> String {
let mut out = std::format!(
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
@@ -737,6 +765,10 @@ fn write_value(
indent: usize,
aliases: &BTreeMap<Id, String>,
) {
if key == "semantic" {
semantic_source::write(out, value, indent);
return;
}
match value {
Value::Null => out.push_str("none"),
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
@@ -780,6 +812,7 @@ enum Ast {
Literal(Value, usize),
Word(String, usize),
Reference(String, usize),
HistoricalUuid(Id, usize),
Array(Vec<Ast>, usize),
Object(BTreeMap<String, Ast>, usize),
}
@@ -789,12 +822,20 @@ impl Ast {
Self::Literal(_, p)
| Self::Word(_, p)
| Self::Reference(_, p)
| Self::HistoricalUuid(_, p)
| Self::Array(_, p)
| Self::Object(_, p) => *p,
}
}
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
match self {
Self::HistoricalUuid(_, _) => Err(Error {
code: "invalid_source",
offset: self.offset(),
message:
"A uuid literal is allowed only in a typed historical semantic reference slot."
.into(),
}),
Self::Word(word, offset) => {
if !enum_field(key) {
return Err(Error {
@@ -962,6 +1003,7 @@ pub(crate) struct EditValue {
pub(crate) enum EditKind {
Atom,
Reference(String),
HistoricalUuid(Id),
Record(BTreeMap<String, EditField>),
List(Vec<EditValue>),
}
@@ -1074,7 +1116,7 @@ fn edit_positions(
let span = Span {
start: ast.offset(),
end: match ast {
Ast::Reference(..) => self.tokens[index + 1].span.end,
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
_ => self.ends[index],
},
};
@@ -1084,6 +1126,7 @@ fn edit_positions(
EditKind::List(items.iter().map(|item| self.value(item)).collect())
}
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
_ => EditKind::Atom,
};
EditValue { span, kind }
@@ -1264,6 +1307,16 @@ impl Parser<'_> {
let token = self.current();
let offset = token.span.start;
match self.text() {
"uuid" => {
self.cursor += 1;
let value = self.quoted()?;
let id = Id::parse_str(&value).map_err(|_| Error {
code: "invalid_source",
offset,
message: "Expected a UUID after uuid.".into(),
})?;
Ok(Ast::HistoricalUuid(id, offset))
}
"@" => {
self.cursor += 1;
Ok(Ast::Reference(self.identifier()?, offset))
@@ -1337,12 +1390,12 @@ impl Parser<'_> {
.map(|(key, span)| ((d.id, key.clone()), span.start)),
);
}
let project =
let decoded =
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
.map_err(|(_, error)| error)?;
Ok(ParsedDocument {
project,
migration: None,
project: decoded.project,
migration: decoded.migration,
locations,
fields,
})
@@ -1350,9 +1403,12 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
if self.text() != "3" {
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
return Err(
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
);
}
let schema_version = self.text().parse().expect("checked source version");
self.cursor += 1;
self.take(";")?;
let start = self.current().span.start;
@@ -1365,7 +1421,7 @@ impl Parser<'_> {
self.take("{")?;
Some(SourceHeader {
project: Project {
schema_version: SCHEMA_VERSION,
schema_version,
id,
alias,
name,
@@ -1540,14 +1596,21 @@ pub(crate) fn resolve_declarations(
declarations: &[Declaration],
aliases: &BTreeMap<String, Id>,
limits: ParseLimits,
) -> Result<Project, (usize, Error)> {
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
let legacy = project.schema_version == 3;
let mut legacy_elements = serde_json::Map::new();
let mut nodes = Vec::new();
for (index, declaration) in declarations.iter().enumerate() {
(|| -> Result<(), Error> {
let mut fields = declaration
.fields
.iter()
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
.map(|(k, v)| {
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
} else { v.resolve(k, aliases)? };
Ok((k.clone(), value))
})
.collect::<Result<Map<_, _>, Error>>()?;
let d = declaration;
let conversion_error = |message: String| Error {
@@ -1561,6 +1624,13 @@ pub(crate) fn resolve_declarations(
};
match d.kind {
DeclKind::Element(kind, layer) => {
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
}
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Element constructor was not part of schema3.".into()));
}
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let explicit_parent = d.fields.contains_key("parent");
@@ -1601,9 +1671,17 @@ pub(crate) fn resolve_declarations(
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
let body_fields =
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
let defaults = if legacy {
crate::persistence::v3::legacy_defaults(kind)
.and_then(|value| value.as_object().cloned())
.unwrap_or_else(|| payload(&body))
} else { payload(&body) };
let body_fields = merge_fields(defaults, fields).map_err(&conversion_error)?;
replace_payload(&mut body, body_fields);
if legacy {
legacy_elements.insert(d.id.to_string(), serde_json::json!({"id":d.id,"alias":d.alias,"name":d.name,"description":description,"parent":parent,"body":body}));
return Ok(());
}
let body: ElementBody = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
if body.kind() != kind || body.layer() != layer {
@@ -1624,9 +1702,18 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Relation(kind) => {
if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); }
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
}
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
}
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
let required: &[&str] = if kind == RelationKind::ContextualLink {
&["owner", "source", "target", "purpose"]
} else if kind == RelationKind::FunctionalExchange {
&["source", "target", "source_port", "target_port"]
} else {
&["source", "target"]
@@ -1657,8 +1744,15 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Diagram => {
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
if project.schema_version < 6 && fields.contains_key("view_options") {
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if project.schema_version < 6 && let Some(key)=fields.keys().find(|key| !matches!(key.as_str(),"description"|"layer"|"projection"|"routes")) {
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[]});
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
@@ -1705,8 +1799,12 @@ pub(crate) fn resolve_declarations(
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
let trace = if project.schema_version < 5 {
let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?;
Trace::from(old)
} else {
serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?
};
project.traces.insert(d.id, trace);
}
}
@@ -1725,6 +1823,81 @@ pub(crate) fn resolve_declarations(
.map_err(|error| (index, error))?;
target.nodes.insert(node.id, node);
}
if legacy {
let mut value = serde_json::to_value(&project).expect("project serialization");
value["elements"] = Value::Object(legacy_elements);
let migrated = serde_json::from_value(value)
.map_err(crate::persistence::FileError::Json)
.and_then(|old| crate::persistence::v3::migrate(old, limits))
.map_err(|error| {
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
issues.first()
} else {
None
};
let index = issue
.and_then(|issue| issue.element)
.and_then(|id| declarations.iter().position(|d| d.id == id))
.unwrap_or(usize::MAX);
let offset = declarations.get(index).map_or(0, |d| {
issue
.and_then(|issue| issue.field.as_ref())
.and_then(|field| d.field_spans.get(field))
.map_or(d.offset, |span| span.start)
});
(
index,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"migration_error"
},
offset,
message: error.to_string(),
},
)
})?;
for (index, d) in declarations.iter().enumerate() {
if let DeclKind::Element(kind, layer) = d.kind {
let body = &migrated.project.elements[&d.id].body;
if body.kind() != kind || body.layer() != layer {
return Err((
index,
Error {
code: "invalid_source",
offset: d.offset,
message: "Declaration kind disagrees with its typed body.".into(),
},
));
}
}
}
return Ok(migrated);
}
if matches!(project.schema_version, 4..=6) {
return (if project.schema_version == 4 {
crate::persistence::v4::finish(project, limits)
} else if project.schema_version == 5 {
crate::persistence::migrate_v5(project, limits)
} else {
crate::persistence::migrate_v6(project, limits)
})
.map_err(|error| {
(
usize::MAX,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"migration_error"
},
offset: 0,
message: error.to_string(),
},
)
});
}
limits.check_model(&project).map_err(|message| {
(
usize::MAX,
@@ -1735,7 +1908,10 @@ pub(crate) fn resolve_declarations(
},
)
})?;
Ok(project)
Ok(crate::persistence::DecodeResult {
project,
migration: None,
})
}
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
@@ -2516,3 +2692,8 @@ pub(crate) mod legacy {
}
}
}
/// Closed historical facet identity for the targeted source writer.
pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
semantic_source::row_key(source, origins)
}
@@ -0,0 +1,211 @@
//! Typed historical source leaves; live alias resolution remains in the parent.
use super::*;
use crate::semantic::{HistoricalSourceKind, SemanticBaseline, historical_source_slots};
fn error(ast: &Ast, message: impl Into<String>) -> Error {
Error {
code: "invalid_source",
offset: ast.offset(),
message: message.into(),
}
}
fn raw(ast: &Ast) -> Result<Value, Error> {
Ok(match ast {
Ast::Literal(value, _) => value.clone(),
Ast::Word(value, _) => Value::String(value.clone()),
Ast::HistoricalUuid(value, _) => Value::String(value.to_string()),
Ast::Reference(_, _) => {
return Err(error(
ast,
"Historical semantic references require uuid \"...\", not @alias.",
));
}
Ast::Array(values, _) => Value::Array(values.iter().map(raw).collect::<Result<_, _>>()?),
Ast::Object(fields, _) => Value::Object(
fields
.iter()
.map(|(key, value)| Ok((key.clone(), raw(value)?)))
.collect::<Result<_, Error>>()?,
),
})
}
fn validate_tokens(
ast: &Ast,
path: &mut Vec<String>,
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
) -> Result<(), Error> {
match ast {
Ast::Object(fields, _) => {
for (key, value) in fields {
path.push(key.clone());
validate_tokens(value, path, slots)?;
path.pop();
}
}
Ast::Array(values, _) => {
for (index, value) in values.iter().enumerate() {
path.push(index.to_string());
validate_tokens(value, path, slots)?;
path.pop();
}
}
_ => match (slots.get(path), ast) {
(Some(HistoricalSourceKind::Reference), Ast::HistoricalUuid(..))
| (Some(HistoricalSourceKind::Enum), Ast::Word(..)) => {}
(Some(HistoricalSourceKind::Reference), _) => {
return Err(error(
ast,
"This historical reference requires uuid \"...\".",
));
}
(Some(HistoricalSourceKind::Enum), _) => {
return Err(error(
ast,
"This typed historical enum requires an unquoted enum word.",
));
}
(None, Ast::HistoricalUuid(..)) => {
return Err(error(
ast,
"A uuid literal is allowed only in a typed historical reference slot.",
));
}
(None, Ast::Word(..)) => {
return Err(error(
ast,
"This historical text value requires a quoted string.",
));
}
_ => {}
},
}
Ok(())
}
pub(super) fn resolve_baseline(
ast: &Ast,
aliases: &BTreeMap<String, Id>,
version: u32,
) -> Result<Value, Error> {
let Ast::Object(fields, _) = ast else {
return ast.resolve("baseline", aliases);
};
let mut resolved = Map::new();
for (key, value) in fields {
if key != "semantic" {
resolved.insert(key.clone(), value.resolve(key, aliases)?);
continue;
}
if version < 5 {
return Err(error(
value,
"Semantic trace baselines require schema/source5; the older baseline contains prose only.",
));
}
if matches!(value, Ast::Literal(Value::Null, _)) {
resolved.insert(key.clone(), Value::Null);
continue;
}
let json = raw(value)?;
if version < 7 {
crate::persistence::reject_source7_fields(&json)
.map_err(|cause| error(value, cause))?;
}
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
if version < 6 {
crate::persistence::check_pre6_baseline(&baseline)
.map_err(|cause| error(value, cause.to_string()))?;
}
let slots = historical_source_slots(&baseline).into_iter().collect();
validate_tokens(value, &mut vec![], &slots)?;
resolved.insert(key.clone(), json);
}
Ok(Value::Object(resolved))
}
pub(super) fn write(out: &mut String, value: &Value, indent: usize) {
if value.is_null() {
out.push_str("none");
return;
}
let baseline: SemanticBaseline =
serde_json::from_value(value.clone()).expect("typed semantic baseline");
let slots = historical_source_slots(&baseline).into_iter().collect();
write_at(out, value, indent, &mut vec![], &slots);
}
fn write_at(
out: &mut String,
value: &Value,
indent: usize,
path: &mut Vec<String>,
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
) {
match value {
Value::Null => out.push_str("none"),
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => out.push_str(&value.to_string()),
Value::String(value) => match slots.get(path) {
Some(HistoricalSourceKind::Reference) => {
out.push_str("uuid ");
out.push_str(&quote(value));
}
Some(HistoricalSourceKind::Enum) => out.push_str(value),
None => out.push_str(&quote(value)),
},
Value::Array(values) => {
out.push('[');
for (index, value) in values.iter().enumerate() {
if index != 0 {
out.push_str(", ");
}
path.push(index.to_string());
write_at(out, value, indent, path, slots);
path.pop();
}
out.push(']');
}
Value::Object(fields) => {
out.push_str("{\n");
for (key, value) in fields {
out.push_str(&" ".repeat(indent + 1));
out.push_str(key);
out.push(' ');
path.push(key.clone());
write_at(out, value, indent + 1, path, slots);
path.pop();
out.push_str(";\n");
}
out.push_str(&" ".repeat(indent));
out.push('}');
}
}
}
/// Recognize only complete typed facet rows. Their closed selector, with real
/// identity provenance applied, owns comments when review rows reorder.
pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
let mut parser = Parser {
source,
tokens: strict_tokens(source).ok()?,
cursor: 0,
value_depth: 0,
limits: ParseLimits::UNLIMITED,
};
let ast = parser.value().ok()?;
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
let mut baseline = SemanticBaseline {
facets: vec![facet],
};
crate::semantic::visit_semantic_references(&mut baseline, |id| {
if let Some(original) = origins.get(id) {
*id = *original;
}
});
serde_json::to_string(&baseline.facets[0].selector())
.ok()
.map(|key| format!("semantic:{key}"))
}
+59 -14
View File
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
fn project_source(body: &str) -> String {
format!(
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
"arcadia 5; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
)
}
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(),
"arcadia 5; project model \"Model\" { include \"children.arcadia\"; }".into(),
),
(
"children.arcadia".into(),
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
#[test]
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
let source = "arcadia 3; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let source = "arcadia 5; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let analysis = analyze(source);
let ready = analysis
.declarations
@@ -402,19 +402,64 @@ fn checked_in_source_project() -> SourceBundle {
}
}
fn assert_architecture_example_warnings(compiled: &crate::source_project::CompiledSourceProject) {
// This architecture-only example intentionally has no process/scenario
// context. Preserve its explicit incompleteness instead of inventing work.
let expected: std::collections::BTreeSet<_> = [
("unspecified_exchange_mechanism", "oa_report_data"),
("unspecified_exchange_mechanism", "sa_report_data"),
("work_without_behavior_context", "report_distress"),
("work_without_behavior_context", "assess_incident"),
("work_without_behavior_context", "transmit_report"),
("work_without_behavior_context", "assess_report"),
("work_without_behavior_context", "review_response"),
("exchange_without_behavior_context", "distress_signal"),
("exchange_without_behavior_context", "validated_report"),
("capability_without_behavior_context", "protect_life"),
("capability_without_behavior_context", "coordinate_response"),
]
.into_iter()
.map(|(code, alias)| (code, Some(identity(&compiled.decoded.project, alias))))
.collect();
let actual: std::collections::BTreeSet<_> = compiled
.diagnostics
.iter()
.map(|issue| {
assert_eq!(issue.diagnostic.severity, validation::Severity::Warning);
(issue.diagnostic.code.as_str(), issue.diagnostic.object_id)
})
.collect();
assert_eq!(actual, expected);
assert_eq!(compiled.diagnostics.len(), expected.len());
}
#[test]
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
let input = checked_in_source_project();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.bundle, input,
"every example identity is already explicit"
);
assert!(
compiled.diagnostics.is_empty(),
"{:?}",
compiled.diagnostics
);
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
for (path, source) in &input.files {
let unit = parse_unit(source, path == &input.entry, ParseLimits::UNLIMITED).unwrap();
assert!(
unit.header
.as_ref()
.is_none_or(|header| header.id_insertion.is_none())
);
for declaration in unit.declarations {
assert!(
declaration.id_insertion.is_none(),
"every example identity is already explicit"
);
assert_eq!(
identity(&compiled.decoded.project, &declaration.alias),
declaration.id
);
}
}
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reopened.bundle, compiled.bundle);
assert!(reopened.decoded.migration.is_none());
assert_architecture_example_warnings(&compiled);
let project = &compiled.decoded.project;
assert_eq!(project.diagrams.len(), 3);
let assess = identity(project, "assess_report");
@@ -473,7 +518,7 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
ProjectLimits::default(),
)
.unwrap();
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
assert_architecture_example_warnings(&result);
assert_eq!(
result.index.declarations[&assess].path,
"sa/assurance.arcadia"
@@ -500,6 +545,6 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
"sa/participants.arcadia",
"views.arcadia",
] {
assert_eq!(result.bundle.files[path], input.files[path]);
assert_eq!(result.bundle.files[path], compiled.bundle.files[path]);
}
}
+6
View File
@@ -1,18 +1,24 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod data;
pub mod language;
pub mod model;
pub mod operations;
pub mod persistence;
pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod semantic;
pub mod session;
pub mod source_files;
pub mod source_project;
pub mod topology_context;
pub mod transitions;
pub mod validation;
pub mod view_options;
pub use model::*;
pub use ports::*;
pub use semantic::*;
pub use session::*;
mod demo;
mod state_validation;
+8 -4
View File
@@ -11,10 +11,14 @@ but constructs and validates the frozen DTO, preserves located old-contract
errors, then calls `adapter.rs`. Original staged scenario/state defaults and
generic port/optional FE endpoint contracts therefore remain explicit.
`adapter.rs` budgets generated data, validates V2, transforms only the changed
payloads, and validates schema 3. Serialization between the two typed DTOs copies
unchanged fields; original input bytes are never deserialized into the new
`Project`. V1 normalization also constructs this V2 DTO before the shared stage.
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
then performs the data-contract upgrade into the strict schema 4 envelope. The
schema 4 review adapter preserves its prose-only trace baselines and returns
schema 5 with semantic review absent. Serialization
between compatibility DTOs copies unchanged fields; original input bytes are
never deserialized into the new `Project`. V1 normalization also constructs this
V2 DTO before the shared stages. Combined generation stays within one budget.
Do not update these frozen shapes when extending the current model. Add another
versioned migration when the persistent contract changes again. Golden V2
+18 -13
View File
@@ -62,16 +62,9 @@ pub(crate) fn migrate(
}],
});
}
let mut report = MigrationReport {
from_schema: 2,
to_schema: 3,
preserved_ports: vec![],
generated_endpoints: vec![],
orientation_defaults: vec![],
required_resolutions: vec![],
};
let mut report = MigrationReport::data_upgrade(2);
let mut value = serde_json::to_value(&old)?;
value["schema_version"] = Value::from(SCHEMA_VERSION);
value["schema_version"] = Value::from(3);
for element in old.elements.values() {
if let Some(super::model::SharedBody::Port {
direction,
@@ -204,11 +197,23 @@ pub(crate) fn migrate(
_ => {}
}
}
let project: Project = serde_json::from_value(value)?;
limits.check_model(&project).map_err(FileError::Limit)?;
super::super::validate_project(&project)?;
// Carry the endpoint-generation reference charge into the next migration;
// independent per-stage checks would undercount the combined generated graph.
let data_limits = ParseLimits {
max_step_references: limits
.max_step_references
.saturating_sub(additional_references),
..limits
};
let migrated = super::super::v3::migrate(serde_json::from_value(value)?, data_limits)?;
let data = migrated
.migration
.expect("the schema3 adapter reports its defaults");
report.semantic_defaults = data.semantic_defaults;
report.generated_interface_allocations = data.generated_interface_allocations;
report.unresolved_message_values = data.unresolved_message_values;
Ok(DecodeResult {
project,
project: migrated.project,
migration: Some(report),
})
}
+33
View File
@@ -0,0 +1,33 @@
# Schema 3 to 4 reader
Schema 3 is a real compatibility input. Its shapes were frozen at `5ce3eef`.
The small DTOs in `mod.rs` own the changed data payloads and old message values;
closed element/relation constructor lists prevent new vocabulary from entering
the old reader. Unchanged ports, diagram placements/routes, artifacts and traces
reuse their typed records. The intermediate JSON bridge rejects duplicate keys.
Nature, bounded types, field defaults/direction/storage, and single supertypes
receive explicit documented defaults. Interface lists become identified ordered
allocations with UUID v5 namespace equal to the project UUID and name
`v2-interface-item:<interface-uuid>:<item-uuid>`. The name remains the same for
V2 and V3 inputs so the migration route cannot change the generated identity.
Existing IDs never move; deterministic collisions fail with a located diagnostic.
Aliases use the normal deterministic collision suffix. Unique legacy message
paths are qualified; ambiguous annotations retain their expression and warning.
V1 first normalizes to the frozen V2 DTO. V2 port/endpoint normalization emits
an explicit schema3 candidate and then runs this reader before current decoding.
Generated object, record and reference budgets cover the combined stages.
`language::resolve_declarations` shares lexical/AST parsing, including nesting,
but chooses frozen source3 payload defaults and rejects new constructors. Both
single-file parsing and `source_project::compile_bundle` take this explicit path.
The bundle compiler first stabilizes omitted IDs, then performs targeted token
edits in `source_project/source_migration.rs`. Original files, includes, lexical
ownership and comments survive. New allocation declarations stay in the source
interface's module. The returned source and index describe schema4 bytes; loading
does not rewrite original disk files. Canonical output and second-save stability
are schema4. No whole-project formatting fallback handles authored source3.
Frozen source/JSON fixtures live under `tests/fixtures/v3-data-contract.*`; the
original V2 golden files remain unchanged.
+632
View File
@@ -0,0 +1,632 @@
//! Explicit schema-3 compatibility. Only changed payloads are frozen here;
//! unchanged typed records are shared after a closed old-constructor check.
use crate::language::ParseLimits;
use crate::persistence::{DecodeResult, FileError, MigrationReport};
use crate::*;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project3 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element3>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation3>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, crate::persistence::v4::Trace4>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Element3 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub parent: Option<Id>,
#[serde(deserialize_with = "strict_value")]
pub body: Value,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Relation3 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "strict_value")]
pub body: Value,
}
// A Value bridge must retain the strict duplicate-field behavior of typed DTOs.
fn strict_value<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Value, D::Error> {
struct Strict;
impl<'de> serde::de::Visitor<'de> for Strict {
type Value = Value;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a value with unique object fields")
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Value, E> {
Ok(v.into())
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Value, E> {
Ok(v.into())
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Value, E> {
Ok(v.into())
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Value, E> {
serde_json::Number::from_f64(v)
.map(Value::Number)
.ok_or_else(|| E::custom("Non-finite number"))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Value, E> {
Ok(v.into())
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Value, E> {
Ok(v.into())
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
let mut out = Vec::new();
while let Some(v) = a.next_element::<StrictValue>()? {
out.push(v.0);
}
Ok(Value::Array(out))
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
let mut out = serde_json::Map::new();
while let Some((k, v)) = a.next_entry::<String, StrictValue>()? {
if out.insert(k.clone(), v.0).is_some() {
return Err(serde::de::Error::custom(format!("Duplicate field '{k}'")));
}
}
Ok(Value::Object(out))
}
}
d.deserialize_any(Strict)
}
struct StrictValue(Value);
impl<'de> Deserialize<'de> for StrictValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
strict_value(d).map(Self)
}
}
#[derive(Deserialize)]
enum ItemKind3 {
Information,
Material,
Energy,
Event,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
enum Definition3 {
Boolean,
Integer,
Real,
String,
Enumeration { literals: Vec<String> },
Record,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Item3 {
kind: ItemKind3,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Type3 {
definition: Definition3,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Field3 {
type_ref: TypeRef,
minimum: u32,
maximum: Option<u32>,
unit: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Interface3 {
items: Vec<Id>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MessageValue3 {
field: Id,
expression: String,
}
pub(crate) fn relation_allowed(name: &str) -> bool {
matches!(
name,
"Allocation"
| "PortAllocation"
| "Interaction"
| "CapabilityInvolvement"
| "ChainInvolvement"
| "RoleRealization"
| "RoleAssignment"
| "FunctionalExchange"
| "BoundaryExchange"
| "MissionCapability"
| "CapabilityInclude"
| "CapabilityExtend"
| "CapabilityGeneralization"
| "MissionInvolvement"
| "SequenceLink"
| "StateTransition"
| "Availability"
| "ExchangeAllocation"
| "InterfaceProvision"
| "InterfaceRequirement"
| "StateAction"
)
}
pub(crate) fn element_allowed(name: &str) -> bool {
matches!(
name,
"Entity"
| "Actor"
| "Role"
| "Activity"
| "Capability"
| "OperationalMission"
| "Process"
| "System"
| "SystemActor"
| "Function"
| "SystemCapability"
| "Mission"
| "FunctionalChain"
| "ExchangeItem"
| "DataType"
| "DataField"
| "Interface"
| "Port"
| "Scenario"
| "Lifeline"
| "Occurrence"
| "StateMachine"
| "Region"
| "State"
| "Mode"
| "Initial"
| "Final"
| "Choice"
| "Junction"
| "Fork"
| "Join"
| "ShallowHistory"
| "DeepHistory"
| "EntryPoint"
| "ExitPoint"
| "Terminate"
| "Event"
| "Constraint"
)
}
pub(crate) fn legacy_defaults(kind: Kind) -> Option<Value> {
Some(match kind {
Kind::ExchangeItem => json!({"kind":"Information"}),
Kind::DataType => json!({"definition":"String"}),
Kind::DataField => json!({"type_ref":"String","minimum":1,"maximum":1,"unit":""}),
Kind::Interface => json!({"items":[]}),
_ => return None,
})
}
fn at(id: Id, field: &str, code: &str, message: impl Into<String>) -> FileError {
let message = message.into();
FileError::Model {
message: format!("V3 {code} at {id}.{field}: {message}"),
issues: vec![validation::Issue {
severity: validation::Severity::Error,
element: Some(id),
code: code.into(),
field: Some(field.into()),
message,
}],
}
}
fn singleton(value: &Value) -> Option<(&str, &Value)> {
let object = value.as_object()?;
(object.len() == 1)
.then(|| object.iter().next().unwrap())
.map(|(k, v)| (k.as_str(), v))
}
fn old_payload<T: serde::de::DeserializeOwned>(
id: Id,
field: &str,
value: Value,
) -> Result<T, FileError> {
serde_json::from_value(value).map_err(|error| at(id, field, "legacy_shape", error.to_string()))
}
pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult, FileError> {
if old.schema_version != 3 {
return Err(FileError::Invalid("Expected schema_version 3.".into()));
}
let graph = old.elements.len().saturating_add(old.relations.len());
let records = graph
.saturating_add(old.diagrams.len())
.saturating_add(old.diagrams.values().map(|d| d.nodes.len()).sum::<usize>())
.saturating_add(old.traces.len())
.saturating_add(old.artifact_links.len())
.saturating_add(1);
limits
.check_counts(graph, records, 0)
.map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(3);
let mut value = serde_json::to_value(&old)?;
let mut allocations = Vec::new();
for (key, e) in &old.elements {
if *key != e.id {
return Err(at(
e.id,
"id",
"identity",
"Element map key differs from its identity.",
));
}
let mut body = e.body.clone();
let (layer, inner) = singleton(&body)
.filter(|(name, _)| matches!(*name, "Oa" | "Sa"))
.ok_or_else(|| {
at(
e.id,
"body",
"legacy_shape",
"Expected one OA or SA constructor.",
)
})?;
let layer = layer.to_owned();
let outer = inner
.as_str()
.or_else(|| singleton(inner).map(|(name, _)| name));
if !outer.is_some_and(|name| {
if layer == "Oa" {
matches!(
name,
"Entity"
| "Actor"
| "Role"
| "Activity"
| "Capability"
| "Mission"
| "Process"
| "Shared"
)
} else {
matches!(
name,
"System"
| "Actor"
| "Function"
| "Capability"
| "Mission"
| "FunctionalChain"
| "Shared"
)
}
}) {
return Err(at(
e.id,
"body",
"legacy_shape",
"Unknown schema3 layer constructor.",
));
}
if let Some(("Shared", shared)) = singleton(inner) {
let name = shared
.as_str()
.or_else(|| singleton(shared).map(|(name, _)| name))
.ok_or_else(|| {
at(
e.id,
"body",
"legacy_shape",
"Expected one schema3 shared constructor.",
)
})?
.to_owned();
if !matches!(
name.as_str(),
"ExchangeItem"
| "DataType"
| "DataField"
| "Interface"
| "Port"
| "Scenario"
| "Lifeline"
| "Occurrence"
| "StateMachine"
| "Event"
| "Region"
| "State"
| "Constraint"
) {
return Err(at(
e.id,
"body",
"legacy_shape",
format!("Unknown schema3 shared constructor '{name}'."),
));
}
let payload = shared.get(&name).cloned().unwrap_or(Value::Null);
let changed = match name.as_str() {
"ExchangeItem" => {
let old: Item3 = old_payload(e.id, "body.kind", payload)?;
let (kind, mechanism) = match old.kind {
ItemKind3::Information => ("Information", "Unspecified"),
ItemKind3::Material => ("Material", "Unspecified"),
ItemKind3::Energy => ("Energy", "Unspecified"),
ItemKind3::Event => ("Information", "Event"),
};
Some(json!({"kind":kind,"mechanism":mechanism,"supertype":null}))
}
"DataType" => {
let old: Type3 = old_payload(e.id, "body.definition", payload)?;
let definition = match old.definition {
Definition3::Boolean => json!("Boolean"),
Definition3::Integer => json!({"Integer":{"minimum":null,"maximum":null}}),
Definition3::Real => json!({"Real":{"minimum":null,"maximum":null}}),
Definition3::String => {
json!({"String":{"minimum_length":0,"maximum_length":null}})
}
Definition3::Enumeration { literals } => {
json!({"Enumeration":{"literals":literals}})
}
Definition3::Record => json!({"Record":{"supertype":null}}),
};
Some(json!({"definition":definition,"default":null,"unit":""}))
}
"DataField" => {
let old: Field3 = old_payload(e.id, "body", payload)?;
Some(
json!({"type_ref":old.type_ref,"minimum":old.minimum,"maximum":old.maximum,"unit":old.unit,"direction":"Unspecified","storage":"Value","default":null}),
)
}
"Interface" => {
let old: Interface3 = old_payload(e.id, "items", payload)?;
let mut seen = BTreeSet::new();
for (order, item) in old.items.into_iter().enumerate() {
if !seen.insert(item) {
return Err(at(
e.id,
"items",
"duplicate_reference",
"Interface items must be unique.",
));
}
allocations.push((e.id, item, order));
}
Some(json!({"supertype":null}))
}
"Occurrence" => {
let mut payload = payload;
if let Some(values) = payload.pointer_mut("/body/Exchange/values") {
let old: Vec<MessageValue3> =
old_payload(e.id, "body.values", values.clone())?;
*values=Value::Array(old.into_iter().map(|v|json!({"field":v.field,"expression":v.expression,"path":[]})).collect());
}
Some(payload)
}
_ => None,
};
if let Some(changed) = changed {
let description = match name.as_str() {
"ExchangeItem" if changed["mechanism"] == "Event" => {
"Legacy Event nature becomes Information with Event mechanism; supertype is absent."
}
"ExchangeItem" => {
"Nature is retained; mechanism is Unspecified and supertype is absent."
}
"DataType" => {
"Existing type is retained with unbounded numeric/string limits, no scalar default or unit, and no Record supertype."
}
"DataField" => {
"Type, ownership, multiplicity and unit are retained; direction is Unspecified, storage is Value and default is absent."
}
"Interface" => {
"Items become identified ordered allocations with Unspecified protocols; supertype is absent."
}
_ => {
"Value expressions are retained; unique enclosing field paths are resolved and ambiguous legacy paths remain unresolved."
}
};
body[&layer]["Shared"][&name] = changed;
report
.semantic_defaults
.push(crate::persistence::SemanticDefault {
object: e.id,
field: "body".into(),
description: description.into(),
});
}
}
// Current deserialization checks the reused, unchanged outer/typed parts.
let _: ElementBody = serde_json::from_value(body.clone())
.map_err(|error| at(e.id, "body", "legacy_shape", error.to_string()))?;
value["elements"][key.to_string()]["body"] = body;
}
for (key, r) in &old.relations {
if *key != r.id {
return Err(at(
r.id,
"id",
"identity",
"Relation map key differs from its identity.",
));
}
if !singleton(&r.body).is_some_and(|(name, _)| relation_allowed(name)) {
return Err(at(
r.id,
"body",
"legacy_shape",
"Relation constructor was not part of schema3.",
));
}
}
value["schema_version"] = 4.into();
let normalized: crate::persistence::v4::Project4 = serde_json::from_value(value)?;
let mut project = normalized.into_current()?;
crate::persistence::v4::review_defaults(&project, &mut report);
let mut references = project
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.sum::<usize>()
.saturating_add(allocations.len().saturating_mul(2));
limits
.check_counts(
project
.elements
.len()
.saturating_add(project.relations.len())
.saturating_add(allocations.len()),
project.identities().len().saturating_add(allocations.len()),
references,
)
.map_err(FileError::Limit)?;
let mut ids: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
for (source, target, order) in allocations {
let id = Id::new_v5(
&project.id,
format!("v2-interface-item:{source}:{target}").as_bytes(),
);
if !ids.insert(id) {
return Err(at(
source,
"items",
"identity_collision",
format!(
"Deterministic interface allocation {id} already belongs to another object."
),
));
}
let name = format!("{} · {}", project.named(source), project.named(target));
let alias = project.fresh_alias(
&format!(
"{}_{}_allocation",
project
.elements
.get(&source)
.map_or("interface", |e| e.alias.as_str()),
project
.elements
.get(&target)
.map_or("item", |e| e.alias.as_str())
),
"interface_item_allocation",
id,
);
project.relations.insert(
id,
Relation {
id,
alias: alias.clone(),
name,
description: String::new(),
body: RelationBody::InterfaceItemAllocation {
source,
target,
order: order.try_into().map_err(|_| {
at(source, "items", "model_limit", "Too many interface items.")
})?,
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
},
},
);
report.generated_interface_allocations.push(
crate::persistence::GeneratedInterfaceAllocation {
id,
source,
target,
alias,
order: order as u32,
},
);
}
let annotations: Vec<_> = project
.elements
.values()
.filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
values,
..
},
..
}) => Some((e.id, *exchange, values.clone())),
_ => None,
})
.collect();
for (owner, exchange, values) in annotations {
let Some(relation) = project.relations.get(&exchange) else {
continue;
};
for (index, value) in values.into_iter().enumerate() {
let resolution =
crate::data::resolve_legacy_field_path(&project, relation, value.field)
.map_err(|message| at(owner, "body.values", "model_limit", message))?;
if let crate::data::LegacyFieldPathResolution::Unique(path) = resolution {
if !path.is_empty() {
references = references.saturating_add(path.len());
limits
.check_counts(
project
.elements
.len()
.saturating_add(project.relations.len()),
project.identities().len(),
references,
)
.map_err(FileError::Limit)?;
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project
.elements
.get_mut(&owner)
.and_then(Element::shared_mut)
{
values[index].path = path;
}
}
} else if matches!(
resolution,
crate::data::LegacyFieldPathResolution::Ambiguous
) {
report.unresolved_message_values.push(crate::persistence::UnresolvedMessageValue { occurrence:owner,field:value.field,reason:"Several carried paths reach this legacy field; choose an explicit argument path.".into() });
}
}
}
limits.check_model(&project).map_err(FileError::Limit)?;
super::validate_project(&project)?;
Ok(DecodeResult {
project,
migration: Some(report),
})
}
+121
View File
@@ -0,0 +1,121 @@
//! Frozen schema4 review envelopes. Other model records are unchanged in schema5.
use crate::language::ParseLimits;
use crate::persistence::{DecodeResult, FileError, MigrationReport, SemanticDefault};
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TraceBaseline4 {
pub oa: TracedProperties,
pub sa: TracedProperties,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Trace4 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub oa: Id,
pub sa: Id,
pub kind: TraceKind,
pub baseline: Option<TraceBaseline4>,
}
impl From<Trace4> for Trace {
fn from(old: Trace4) -> Self {
Self {
id: old.id,
alias: old.alias,
name: old.name,
description: old.description,
oa: old.oa,
sa: old.sa,
kind: old.kind,
baseline: old.baseline.map(|baseline| TraceBaseline {
oa: baseline.oa,
sa: baseline.sa,
semantic: None,
}),
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project4 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace4>,
}
impl Project4 {
pub(crate) fn into_current(self) -> Result<Project, FileError> {
if self.schema_version != 4 {
return Err(FileError::Invalid("Expected schema_version 4.".into()));
}
crate::persistence::check_pre6_relations(self.relations.values())?;
crate::persistence::check_pre6_elements(self.elements.values())?;
Ok(Project {
schema_version: SCHEMA_VERSION,
id: self.id,
alias: self.alias,
name: self.name,
description: self.description,
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
artifact_links: self.artifact_links,
traces: self
.traces
.into_iter()
.map(|(id, trace)| (id, trace.into()))
.collect(),
})
}
}
pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
report.semantic_defaults.extend(
project
.traces
.values()
.filter(|trace| trace.baseline.is_some())
.map(|trace| SemanticDefault {
object: trace.id,
field: "baseline.semantic".into(),
description: "Original prose review preserved; semantic facets remain unreviewed."
.into(),
}),
);
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
crate::persistence::check_pre6_relations(project.relations.values())?;
crate::persistence::check_pre6_elements(project.elements.values())?;
project.schema_version = SCHEMA_VERSION;
limits.check_model(&project).map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(4);
review_defaults(&project, &mut report);
Ok(DecodeResult {
project,
migration: Some(report),
})
}
pub(crate) fn migrate(old: Project4, limits: ParseLimits) -> Result<DecodeResult, FileError> {
finish(old.into_current()?, limits)
}
+262 -20
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 3;
pub const SCHEMA_VERSION: u32 = 7;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -39,6 +39,7 @@ pub enum Kind {
Mission,
FunctionalChain,
ExchangeItem,
ExchangeCategory,
DataType,
DataField,
Interface,
@@ -65,7 +66,7 @@ pub enum Kind {
Constraint,
}
impl Kind {
pub const ALL: [Self; 38] = [
pub const ALL: [Self; 39] = [
Self::Entity,
Self::Actor,
Self::Role,
@@ -80,6 +81,7 @@ impl Kind {
Self::Mission,
Self::FunctionalChain,
Self::ExchangeItem,
Self::ExchangeCategory,
Self::DataType,
Self::DataField,
Self::Interface,
@@ -121,6 +123,7 @@ impl Kind {
Self::Mission => "Mission",
Self::FunctionalChain => "Functional chain",
Self::ExchangeItem => "Exchange item",
Self::ExchangeCategory => "Exchange category",
Self::DataType => "Data type",
Self::DataField => "Data field",
Self::Interface => "Interface",
@@ -265,17 +268,79 @@ pub enum ExchangeItemKind {
Information,
Material,
Energy,
Event,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ExchangeMechanism {
#[default]
Unspecified,
Flow,
Event,
Operation,
SharedData,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ParameterDirection {
#[default]
Unspecified,
In,
Out,
InOut,
Return,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FieldStorage {
#[default]
Value,
Reference,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataValue {
Boolean(bool),
Integer(i64),
Real(f64),
String(String),
Enumeration(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TransmissionProtocol {
#[default]
Unspecified,
Synchronous,
Asynchronous,
Unicast,
Multicast,
Broadcast,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AcquisitionProtocol {
#[default]
Unspecified,
Read,
Accept,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataTypeDefinition {
Boolean,
Integer,
Real,
String,
Enumeration { literals: Vec<String> },
Record,
Integer {
minimum: Option<i64>,
maximum: Option<i64>,
},
Real {
minimum: Option<f64>,
maximum: Option<f64>,
},
String {
minimum_length: u32,
maximum_length: Option<u32>,
},
Enumeration {
literals: Vec<String>,
},
Record {
supertype: Option<Id>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -453,9 +518,33 @@ pub struct LifelineBinding {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MessageValue {
#[serde(default)]
pub path: Vec<Id>,
pub field: Id,
pub expression: String,
}
/// Explicit operation intent. A missing allocation remains an unfinished operation,
/// whereas an absent OperationUse retains ordinary channel-message semantics.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperationUse {
pub allocation: Option<Id>,
}
/// A contribution in a caller instance. Paths contain reference occurrence IDs,
/// never sequence definitions; repeated references therefore remain distinct.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OccurrenceEndpoint {
pub references: Vec<Id>,
pub occurrence: Id,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ContextualLinkPurpose {
Precedence { guard: String },
Exchange { exchange: Id },
CommonWork { work: Id },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum OccurrenceBody {
@@ -469,6 +558,8 @@ pub enum OccurrenceBody {
reply_to: Option<Id>,
#[serde(default)]
values: Vec<MessageValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
operation: Option<OperationUse>,
},
Action {
action: Option<Id>,
@@ -500,20 +591,31 @@ pub enum OccurrenceBody {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum SharedBody {
ExchangeCategory {
family: crate::view_options::ExchangeFamily,
exchanges: Vec<Id>,
},
ExchangeItem {
kind: ExchangeItemKind,
mechanism: ExchangeMechanism,
supertype: Option<Id>,
},
DataType {
definition: DataTypeDefinition,
default: Option<DataValue>,
unit: String,
},
DataField {
type_ref: TypeRef,
minimum: u32,
maximum: Option<u32>,
unit: String,
direction: ParameterDirection,
storage: FieldStorage,
default: Option<Vec<DataValue>>,
},
Interface {
items: Vec<Id>,
supertype: Option<Id>,
},
Port {
contract: PortContract,
@@ -672,17 +774,31 @@ impl ElementBody {
match k {
Kind::ExchangeItem => SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
},
Kind::DataType => SharedBody::DataType {
definition: DataTypeDefinition::String,
definition: DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
default: None,
unit: String::new(),
},
Kind::ExchangeCategory => SharedBody::ExchangeCategory {
family: crate::view_options::ExchangeFamily::Work,
exchanges: vec![],
},
Kind::DataField => SharedBody::DataField {
type_ref: TypeRef::String,
minimum: 1,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
},
Kind::Interface => SharedBody::Interface { items: vec![] },
Kind::Interface => SharedBody::Interface { supertype: None },
// Port creation requires an explicit contract and owner.
Kind::Port => return None,
Kind::Scenario => SharedBody::Scenario {
@@ -705,6 +821,7 @@ impl ElementBody {
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
@@ -734,6 +851,7 @@ impl SharedBody {
pub fn kind(&self) -> Kind {
match self {
Self::ExchangeItem { .. } => Kind::ExchangeItem,
Self::ExchangeCategory { .. } => Kind::ExchangeCategory,
Self::DataType { .. } => Kind::DataType,
Self::DataField { .. } => Kind::DataField,
Self::Interface { .. } => Kind::Interface,
@@ -752,6 +870,8 @@ impl SharedBody {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationKind {
InterfaceItemAllocation,
DataAssociation,
Allocation,
PortAllocation,
Interaction,
@@ -761,12 +881,14 @@ pub enum RelationKind {
RoleAssignment,
FunctionalExchange,
BoundaryExchange,
CommunicationMean,
MissionCapability,
CapabilityInclude,
CapabilityExtend,
CapabilityGeneralization,
MissionInvolvement,
SequenceLink,
ContextualLink,
StateTransition,
Availability,
ExchangeAllocation,
@@ -775,7 +897,9 @@ pub enum RelationKind {
StateAction,
}
impl RelationKind {
pub const ALL: [Self; 21] = [
pub const ALL: [Self; 25] = [
Self::InterfaceItemAllocation,
Self::DataAssociation,
Self::Allocation,
Self::PortAllocation,
Self::Interaction,
@@ -785,12 +909,14 @@ impl RelationKind {
Self::RoleAssignment,
Self::FunctionalExchange,
Self::BoundaryExchange,
Self::CommunicationMean,
Self::MissionCapability,
Self::CapabilityInclude,
Self::CapabilityExtend,
Self::CapabilityGeneralization,
Self::MissionInvolvement,
Self::SequenceLink,
Self::ContextualLink,
Self::StateTransition,
Self::Availability,
Self::ExchangeAllocation,
@@ -800,6 +926,8 @@ impl RelationKind {
];
pub fn permits(self, from: Kind, to: Kind) -> bool {
match self {
Self::InterfaceItemAllocation => from == Kind::Interface && to == Kind::ExchangeItem,
Self::DataAssociation => from == Kind::DataType && to == Kind::DataType,
Self::Allocation => from.is_performer() && to.is_work(),
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
@@ -815,6 +943,10 @@ impl RelationKind {
Self::RoleAssignment => from == Kind::Role && to == Kind::Activity,
Self::FunctionalExchange => from == Kind::Function && to == Kind::Function,
Self::BoundaryExchange => from == Kind::Port && to == Kind::Port,
Self::CommunicationMean => {
matches!(from, Kind::Entity | Kind::Actor)
&& matches!(to, Kind::Entity | Kind::Actor)
}
Self::MissionCapability => matches!(
(from, to),
(Kind::OperationalMission, Kind::Capability)
@@ -832,7 +964,9 @@ impl RelationKind {
(Kind::Entity | Kind::Actor, Kind::OperationalMission)
| (Kind::System | Kind::SystemActor, Kind::Mission)
),
Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence,
Self::SequenceLink | Self::ContextualLink => {
from == Kind::Occurrence && to == Kind::Occurrence
}
Self::StateTransition => from.is_state() && to.is_state(),
Self::ExchangeAllocation => false,
Self::InterfaceProvision | Self::InterfaceRequirement => {
@@ -855,6 +989,8 @@ impl RelationKind {
}
pub fn hint(self) -> &'static str {
match self {
Self::InterfaceItemAllocation => "Interface → allocated exchange item",
Self::DataAssociation => "Record type → record type",
Self::Allocation => "Performer → leaf activity/function",
Self::PortAllocation => "Component port → allocated function port",
Self::Interaction => "OA activity → OA activity",
@@ -864,6 +1000,7 @@ impl RelationKind {
Self::RoleAssignment => "OA role → activity",
Self::FunctionalExchange => "SA function → SA function",
Self::BoundaryExchange => "Output port → input port",
Self::CommunicationMean => "OA entity/actor → OA entity/actor",
Self::MissionCapability => "Mission → capability in the same perspective",
Self::CapabilityInclude => "Including capability → included capability",
Self::CapabilityExtend => {
@@ -872,9 +1009,12 @@ impl RelationKind {
Self::CapabilityGeneralization => "Specific capability → general capability",
Self::MissionInvolvement => "Participant → mission in the same perspective",
Self::SequenceLink => "Preceding → following contribution in the same process or chain",
Self::ContextualLink => {
"Contributions addressed in an explicit process or chain caller"
}
Self::StateTransition => "State → state in one machine",
Self::ExchangeAllocation => {
"Boundary exchange → operational interaction / functional exchange"
"Communication means / boundary exchange → operational interaction / functional exchange"
}
Self::InterfaceProvision => "Participant/port → provided interface",
Self::InterfaceRequirement => "Participant/port → required interface",
@@ -886,6 +1026,8 @@ impl RelationKind {
impl fmt::Display for RelationKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::InterfaceItemAllocation => "Interface item allocation",
Self::DataAssociation => "Data association",
Self::Allocation => "Allocation",
Self::PortAllocation => "Port allocation",
Self::Interaction => "Interaction",
@@ -895,12 +1037,14 @@ impl fmt::Display for RelationKind {
Self::RoleAssignment => "Role assignment",
Self::FunctionalExchange => "Functional exchange",
Self::BoundaryExchange => "Boundary exchange",
Self::CommunicationMean => "Communication means",
Self::MissionCapability => "Mission capability",
Self::CapabilityInclude => "Capability include",
Self::CapabilityExtend => "Capability extend",
Self::CapabilityGeneralization => "Capability generalization",
Self::MissionInvolvement => "Mission involvement",
Self::SequenceLink => "Sequence link",
Self::ContextualLink => "Contextual link",
Self::StateTransition => "State transition",
Self::ExchangeAllocation => "Exchange allocation",
Self::InterfaceProvision => "Interface provision",
@@ -922,6 +1066,25 @@ pub struct Relation {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum RelationBody {
InterfaceItemAllocation {
source: Id,
target: Id,
order: u32,
transmission: TransmissionProtocol,
acquisition: AcquisitionProtocol,
},
DataAssociation {
source: Id,
target: Id,
source_role: String,
target_role: String,
source_minimum: u32,
source_maximum: Option<u32>,
target_minimum: u32,
target_maximum: Option<u32>,
source_navigable: bool,
target_navigable: bool,
},
Allocation {
source: Id,
target: Id,
@@ -964,6 +1127,12 @@ pub enum RelationBody {
oriented: bool,
items: Vec<Id>,
},
CommunicationMean {
source: Id,
target: Id,
oriented: bool,
items: Vec<Id>,
},
MissionCapability {
source: Id,
target: Id,
@@ -990,6 +1159,12 @@ pub enum RelationBody {
target: Id,
guard: String,
},
ContextualLink {
owner: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
purpose: ContextualLinkPurpose,
},
StateTransition {
source: Id,
target: Id,
@@ -1026,6 +1201,13 @@ pub enum RelationBody {
},
}
impl Relation {
/// Caller ownership cannot be inferred from a contextual link's leaf IDs.
pub fn contextual_owner(&self) -> Option<Id> {
match self.body {
RelationBody::ContextualLink { owner, .. } => Some(owner),
_ => None,
}
}
pub fn kind(&self) -> RelationKind {
self.body.kind()
}
@@ -1039,6 +1221,8 @@ impl Relation {
impl RelationBody {
pub fn kind(&self) -> RelationKind {
match self {
Self::InterfaceItemAllocation { .. } => RelationKind::InterfaceItemAllocation,
Self::DataAssociation { .. } => RelationKind::DataAssociation,
Self::Allocation { .. } => RelationKind::Allocation,
Self::PortAllocation { .. } => RelationKind::PortAllocation,
Self::Interaction { .. } => RelationKind::Interaction,
@@ -1048,12 +1232,14 @@ impl RelationBody {
Self::RoleAssignment { .. } => RelationKind::RoleAssignment,
Self::FunctionalExchange { .. } => RelationKind::FunctionalExchange,
Self::BoundaryExchange { .. } => RelationKind::BoundaryExchange,
Self::CommunicationMean { .. } => RelationKind::CommunicationMean,
Self::MissionCapability { .. } => RelationKind::MissionCapability,
Self::CapabilityInclude { .. } => RelationKind::CapabilityInclude,
Self::CapabilityExtend { .. } => RelationKind::CapabilityExtend,
Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization,
Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement,
Self::SequenceLink { .. } => RelationKind::SequenceLink,
Self::ContextualLink { .. } => RelationKind::ContextualLink,
Self::StateTransition { .. } => RelationKind::StateTransition,
Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation,
Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision,
@@ -1064,7 +1250,10 @@ impl RelationBody {
}
pub fn endpoints(&self) -> (Id, Id) {
match self {
Self::Allocation { source, target }
Self::ContextualLink { source, target, .. } => (source.occurrence, target.occurrence),
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
@@ -1073,6 +1262,7 @@ impl RelationBody {
| Self::RoleAssignment { source, target }
| Self::FunctionalExchange { source, target, .. }
| Self::BoundaryExchange { source, target, .. }
| Self::CommunicationMean { source, target, .. }
| Self::MissionCapability { source, target }
| Self::CapabilityInclude { source, target }
| Self::CapabilityExtend { source, target, .. }
@@ -1089,7 +1279,13 @@ impl RelationBody {
}
pub fn set_endpoints(&mut self, a: Id, b: Id) {
match self {
Self::Allocation { source, target }
Self::ContextualLink { source, target, .. } => {
source.occurrence = a;
target.occurrence = b;
}
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
@@ -1098,6 +1294,7 @@ impl RelationBody {
| Self::RoleAssignment { source, target }
| Self::FunctionalExchange { source, target, .. }
| Self::BoundaryExchange { source, target, .. }
| Self::CommunicationMean { source, target, .. }
| Self::MissionCapability { source, target }
| Self::CapabilityInclude { source, target }
| Self::CapabilityExtend { source, target, .. }
@@ -1119,12 +1316,32 @@ impl RelationBody {
match self {
Self::Interaction { items, .. }
| Self::FunctionalExchange { items, .. }
| Self::BoundaryExchange { items, .. } => items,
| Self::BoundaryExchange { items, .. }
| Self::CommunicationMean { items, .. } => items,
_ => &[],
}
}
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
match kind {
RelationKind::InterfaceItemAllocation => Self::InterfaceItemAllocation {
source,
target,
order: 0,
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
},
RelationKind::DataAssociation => Self::DataAssociation {
source,
target,
source_role: String::new(),
target_role: String::new(),
source_minimum: 0,
source_maximum: None,
target_minimum: 0,
target_maximum: None,
source_navigable: false,
target_navigable: false,
},
RelationKind::Allocation => Self::Allocation { source, target },
RelationKind::PortAllocation => Self::PortAllocation { source, target },
RelationKind::Interaction => Self::Interaction {
@@ -1150,6 +1367,12 @@ impl RelationBody {
oriented: true,
items: vec![],
},
RelationKind::CommunicationMean => Self::CommunicationMean {
source,
target,
oriented: false,
items: vec![],
},
RelationKind::MissionCapability => Self::MissionCapability { source, target },
RelationKind::CapabilityInclude => Self::CapabilityInclude { source, target },
RelationKind::CapabilityExtend => Self::CapabilityExtend {
@@ -1166,6 +1389,20 @@ impl RelationBody {
target,
guard: String::new(),
},
RelationKind::ContextualLink => Self::ContextualLink {
owner: Id::nil(),
source: OccurrenceEndpoint {
references: vec![],
occurrence: source,
},
target: OccurrenceEndpoint {
references: vec![],
occurrence: target,
},
purpose: ContextualLinkPurpose::Precedence {
guard: String::new(),
},
},
RelationKind::StateTransition => Self::StateTransition {
source,
target,
@@ -1252,6 +1489,8 @@ pub struct Diagram {
pub description: String,
pub layer: Layer,
pub projection: ProjectionKind,
#[serde(default)]
pub view_options: crate::view_options::ViewOptions,
#[serde(deserialize_with = "unique_id_map")]
pub nodes: BTreeMap<Id, NodePlacement>,
#[serde(default)]
@@ -1357,13 +1596,15 @@ pub struct TracedProperties {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TraceBaseline {
pub oa: TracedProperties,
pub sa: TracedProperties,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<crate::semantic::SemanticBaseline>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Trace {
pub id: Id,
@@ -1677,6 +1918,7 @@ impl Project {
description: String::new(),
layer,
projection: ProjectionKind::Overview,
view_options: crate::view_options::ViewOptions::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
+345
View File
@@ -0,0 +1,345 @@
//! One operation resolver for admission, forms and scenario labels.
use crate::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedOperation {
/// The actual Call/Asynchronous declaration, also when resolving its Reply.
pub invocation: Id,
pub allocation: Id,
pub interface: Id,
pub item: Id,
pub caller_port: Id,
pub provider_port: Id,
pub transmission: TransmissionProtocol,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperationResolution {
Ordinary,
Unresolved { invocation: Id, reason: String },
Selected(ResolvedOperation),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct OperationError {
pub code: &'static str,
pub message: String,
}
fn error(code: &'static str, message: &str) -> OperationError {
OperationError {
code,
message: message.into(),
}
}
pub fn resolve(p: &Project, occurrence: Id) -> Result<OperationResolution, OperationError> {
let e = p
.elements
.get(&occurrence)
.ok_or_else(|| error("operation_context", "The message no longer exists."))?;
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
operation,
message_kind,
reply_to,
..
},
..
}) = e.shared()
else {
return Ok(OperationResolution::Ordinary);
};
let invocation = if *message_kind == MessageKind::Reply {
if operation.is_some() {
return Err(error(
"reply_operation",
"A Reply derives its operation from its exact Call; it cannot store a second selection.",
));
}
let Some(call) = reply_to else {
return Ok(OperationResolution::Ordinary);
};
if !matches!(
p.elements.get(call).and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
},
..
})
) {
return Ok(OperationResolution::Ordinary);
}
*call
} else {
occurrence
};
let Some(SharedBody::Occurrence {
body:
body @ OccurrenceBody::Exchange {
operation: Some(operation),
message_kind,
exchange,
sender,
receiver,
..
},
..
}) = p.elements.get(&invocation).and_then(Element::shared)
else {
return Ok(OperationResolution::Ordinary);
};
if p.layer_of(invocation) != Some(Layer::Sa)
|| !matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous)
|| validation::ancestor_of_kind(p, invocation, Kind::Scenario)
.and_then(|id| p.elements.get(&id))
.and_then(Element::shared)
.is_none_or(|body| {
!matches!(
body,
SharedBody::Scenario {
viewpoint: ScenarioViewpoint::BoundaryInterface,
..
}
)
})
{
return Err(error(
"operation_context",
"Operation use belongs to a Call or Asynchronous message in an SA BoundaryInterface scenario.",
));
}
let unresolved = |reason: &str| {
Ok(OperationResolution::Unresolved {
invocation,
reason: reason.into(),
})
};
if exchange
.and_then(|id| p.relations.get(&id))
.is_some_and(|r| r.kind() != RelationKind::BoundaryExchange)
{
return Err(error(
"operation_channel",
"Operation use requires an explicit boundary exchange channel.",
));
}
let Some(allocation) = operation.allocation else {
return unresolved(
"Choose the interface operation allocation; existing value expressions are preserved.",
);
};
let Some(relation) = p.relations.get(&allocation) else {
return Err(error(
"operation_allocation",
"The selected interface operation allocation no longer exists.",
));
};
let RelationBody::InterfaceItemAllocation {
source: interface,
target: item,
transmission,
..
} = relation.body
else {
return Err(error(
"operation_allocation",
"Choose an InterfaceItemAllocation, not an item or another relationship.",
));
};
if p.layer_of(allocation) != Some(Layer::Sa)
|| !matches!(
p.elements.get(&item).and_then(Element::shared),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Operation,
..
})
)
{
return Err(error(
"operation_allocation",
"The selected SA allocation must declare an Information item with Operation mechanism.",
));
}
let Some(channel) = exchange.and_then(|id| p.relations.get(&id)) else {
return Err(error(
"operation_channel",
"A selected operation requires an existing boundary exchange carrying its item; clear the allocation to retain unresolved intent.",
));
};
if channel.kind() != RelationKind::BoundaryExchange
|| !channel
.body
.items()
.iter()
.any(|carried| data::item_satisfies(p, *carried, item))
{
return Err(error(
"operation_channel",
"The boundary exchange must carry the selected operation item or an admitted subtype.",
));
}
if sender.is_none() || receiver.is_none() {
return Err(error(
"operation_lifelines",
"A selected operation requires actual caller and provider lifelines; clear the allocation to retain unresolved intent.",
));
}
let (caller_port, provider_port) = match validation::resolve_message_endpoints(p, body) {
validation::MessageEndpointResolution::Resolved { source, target } => (source, target),
validation::MessageEndpointResolution::Conflict => {
return Err(error(
"operation_direction",
"The selected lifelines conflict with the boundary channel's actual caller/provider direction.",
));
}
_ => {
return Err(error(
"operation_direction",
"A selected operation requires a proven actual caller/provider channel direction; clear the allocation to retain unresolved intent.",
));
}
};
for port in [caller_port, provider_port] {
if !matches!(
p.elements.get(&port).and_then(ports::port_contract),
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
})
) {
return Err(error(
"operation_ports",
"Operation proof requires concrete Standard component ports.",
));
}
}
let exposes = |interface| {
data::effective_interface_allocations(p, interface)
.iter()
.any(|r| r.id == allocation)
};
let providers: Vec<_> = p
.relations
.values()
.filter(|r| {
r.kind() == RelationKind::InterfaceProvision
&& r.source() == provider_port
&& exposes(r.target())
})
.map(Relation::target)
.collect();
let valid = p.relations.values().any(|r| {
r.kind() == RelationKind::InterfaceRequirement
&& r.source() == caller_port
&& exposes(r.target())
&& providers
.iter()
.any(|provided| data::interface_satisfies(p, *provided, r.target()))
});
if !valid {
return Err(error(
"operation_roles",
"The actual receiver port must provide, and sender port require, compatible interfaces exposing this exact allocation.",
));
}
if !matches!(
(message_kind, transmission),
(_, TransmissionProtocol::Unspecified)
| (MessageKind::Call, TransmissionProtocol::Synchronous)
| (
MessageKind::Asynchronous,
TransmissionProtocol::Asynchronous
)
) {
return Err(error(
"operation_transmission",
"Calls require synchronous transmission; asynchronous operation messages require asynchronous transmission.",
));
}
let selected = ResolvedOperation {
invocation,
allocation,
interface,
item,
caller_port,
provider_port,
transmission,
};
if occurrence != invocation {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: reply_exchange,
..
},
..
}) = e.shared()
else {
unreachable!()
};
if let Some(response) = reply_exchange.and_then(|id| p.relations.get(&id))
&& response.id != channel.id
&& !response
.body
.items()
.iter()
.any(|carried| data::item_satisfies(p, *carried, item))
{
return Err(error(
"operation_reply_channel",
"A separate reply channel must carry the Call's selected operation item or an admitted subtype, including parameterless returns.",
));
}
}
Ok(OperationResolution::Selected(selected))
}
/// Valid selections under the proposed message context, without mutating the model.
pub fn eligible_allocations(p: &Project, occurrence: Id) -> Vec<Id> {
let mut candidate = p.clone();
let ids: Vec<_> = p
.relations
.values()
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
.map(|r| r.id)
.collect();
ids.into_iter()
.filter(|id| {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = candidate
.elements
.get_mut(&occurrence)
.and_then(Element::shared_mut)
else {
return false;
};
*operation = Some(OperationUse {
allocation: Some(*id),
});
matches!(
resolve(&candidate, occurrence),
Ok(OperationResolution::Selected(_))
)
})
.collect()
}
/// Reuse the existing bounded field-path engine with exactly one selected item.
pub fn signature_relation(operation: &ResolvedOperation) -> Relation {
Relation {
id: operation.allocation,
alias: String::new(),
name: String::new(),
description: String::new(),
body: RelationBody::Interaction {
source: operation.caller_port,
target: operation.provider_port,
items: vec![operation.item],
},
}
}
+295 -2
View File
@@ -5,6 +5,10 @@ use thiserror::Error;
#[path = "migration_v2/mod.rs"]
pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
#[path = "migration_v3/mod.rs"]
pub(crate) mod v3;
#[path = "migration_v4.rs"]
pub(crate) mod v4;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -15,6 +19,50 @@ pub struct MigrationReport {
pub generated_endpoints: Vec<GeneratedEndpoint>,
pub orientation_defaults: Vec<OrientationDefault>,
pub required_resolutions: Vec<Id>,
#[serde(default)]
pub semantic_defaults: Vec<SemanticDefault>,
#[serde(default)]
pub generated_interface_allocations: Vec<GeneratedInterfaceAllocation>,
#[serde(default)]
pub unresolved_message_values: Vec<UnresolvedMessageValue>,
}
impl MigrationReport {
pub(crate) fn data_upgrade(from_schema: u32) -> Self {
Self {
from_schema,
to_schema: SCHEMA_VERSION,
preserved_ports: vec![],
generated_endpoints: vec![],
orientation_defaults: vec![],
required_resolutions: vec![],
semantic_defaults: vec![],
generated_interface_allocations: vec![],
unresolved_message_values: vec![],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticDefault {
pub object: Id,
pub field: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GeneratedInterfaceAllocation {
pub id: Id,
pub source: Id,
pub target: Id,
pub alias: String,
pub order: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnresolvedMessageValue {
pub occurrence: Id,
pub field: Id,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -91,6 +139,43 @@ pub fn decode_with_report_limited(
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let header: serde_json::Value = serde_json::from_slice(bytes)?;
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 6)
{
reject_source7_fields(&header).map_err(FileError::Invalid)?;
}
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
match header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
@@ -101,13 +186,17 @@ pub fn decode_with_report_limited(
migrate_v1_with_report_limited(old, limits)?
}
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => DecodeResult {
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
Some(7) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
));
}
}
@@ -136,6 +225,210 @@ pub fn decode_with_report_limited(
Ok(decoded)
}
/// Versions 4 and 5 share unchanged typed records, but their constructor set
/// remains frozen. Apply this before normalizing the advertised version.
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
matches!(
kind,
RelationKind::InterfaceItemAllocation
| RelationKind::DataAssociation
| RelationKind::Allocation
| RelationKind::PortAllocation
| RelationKind::Interaction
| RelationKind::CapabilityInvolvement
| RelationKind::ChainInvolvement
| RelationKind::RoleRealization
| RelationKind::RoleAssignment
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::MissionCapability
| RelationKind::CapabilityInclude
| RelationKind::CapabilityExtend
| RelationKind::CapabilityGeneralization
| RelationKind::MissionInvolvement
| RelationKind::SequenceLink
| RelationKind::StateTransition
| RelationKind::Availability
| RelationKind::ExchangeAllocation
| RelationKind::InterfaceProvision
| RelationKind::InterfaceRequirement
| RelationKind::StateAction
)
}
pub(crate) fn pre6_element_kind(kind: Kind) -> bool {
matches!(
kind,
Kind::Entity
| Kind::Actor
| Kind::Role
| Kind::Activity
| Kind::Capability
| Kind::OperationalMission
| Kind::Process
| Kind::System
| Kind::SystemActor
| Kind::Function
| Kind::SystemCapability
| Kind::Mission
| Kind::FunctionalChain
| Kind::ExchangeItem
| Kind::DataType
| Kind::DataField
| Kind::Interface
| Kind::Port
| Kind::Scenario
| Kind::Lifeline
| Kind::Occurrence
| Kind::StateMachine
| Kind::Region
| Kind::State
| Kind::Mode
| Kind::Initial
| Kind::Final
| Kind::Choice
| Kind::Junction
| Kind::Fork
| Kind::Join
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint
| Kind::Terminate
| Kind::Event
| Kind::Constraint
)
}
pub(crate) fn check_pre6_relations<'a>(
relations: impl Iterator<Item = &'a Relation>,
) -> Result<(), FileError> {
for relation in relations {
if !pre6_relation_kind(relation.kind()) {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 6.",
relation.kind()
)));
}
}
Ok(())
}
pub(crate) fn check_pre6_elements<'a>(
elements: impl Iterator<Item = &'a Element>,
) -> Result<(), FileError> {
for element in elements {
if !pre6_element_kind(element.kind()) {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 6.",
element.kind()
)));
}
}
Ok(())
}
pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), FileError> {
if baseline.facets.iter().any(|f| {
matches!(
f,
FacetReview::Category(_)
| FacetReview::Member {
collection: ReferenceCollection::CategoryExchanges,
..
}
)
}) {
return Err(FileError::Invalid(
"Exchange category semantic facts require schema/source 6.".into(),
));
}
Ok(())
}
pub(crate) fn migrate_v5(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
.traces
.values()
.filter_map(|t| t.baseline.as_ref().and_then(|b| b.semantic.as_ref()))
{
check_pre6_baseline(baseline)?;
}
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(5)),
})
}
/// Reject explicit new fields before serde defaults can erase their presence.
/// This is also applied to source's resolved structured values.
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
}
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null.".into(),
);
}
for nested in fields.values() {
reject_source7_fields(nested)?;
}
}
serde_json::Value::Array(values) => {
for nested in values {
reject_source7_fields(nested)?;
}
}
_ => {}
}
Ok(())
}
pub(crate) fn migrate_v6(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(6)),
})
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
+55 -19
View File
@@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest {
pub outgoing_items: Vec<Id>,
}
/// R6 specialization has one explicit seam; current models support identity only.
pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool {
carried == declared
/// A specialized carried item satisfies a declared base item.
pub fn item_satisfies(project: &Project, carried: Id, declared: Id) -> bool {
crate::data::item_satisfies(project, carried, declared)
}
pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool {
carried.iter().all(|item| {
@@ -130,13 +130,7 @@ fn declared_interface_items(p: &Project, port: Id) -> Option<Vec<Id>> {
Some(
interfaces
.into_iter()
.filter_map(|id| p.elements.get(&id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.flat_map(|id| crate::data::interface_items(p, id))
.collect(),
)
}
@@ -903,14 +897,10 @@ fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mu
..
}) => {
let mut allowed = declared_interface_items(p, port.id);
if let Some(SharedBody::Interface { items }) = legacy
.as_ref()
.and_then(|l| l.interface)
.and_then(|id| p.elements.get(&id))
.and_then(Element::shared)
{
if let Some(interface) = legacy.as_ref().and_then(|l| l.interface) {
let items = crate::data::interface_items(p, interface);
if r.kind() == RelationKind::BoundaryExchange
&& !items_satisfy(p, r.body.items(), items)
&& !items_satisfy(p, r.body.items(), &items)
{
error(
out,
@@ -1173,7 +1163,7 @@ pub(crate) fn validate_boundary_interface_requirements(
if !p.relations.values().any(|role| {
role.kind() == RelationKind::InterfaceProvision
&& role.source() == peer.id
&& role.target() == required.target()
&& crate::data::interface_satisfies(p, role.target(), required.target())
}) {
warning(
out,
@@ -1192,6 +1182,48 @@ pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut
else {
return;
};
if let RelationBody::CommunicationMean {
source,
target,
oriented,
..
} = &boundary.body
{
let actual = (
actual_performer(p, work.source()),
actual_performer(p, work.target()),
);
let compatible = |a, b| {
actual.0.is_none_or(|owner| owner == a) && actual.1.is_none_or(|owner| owner == b)
};
if !compatible(*source, *target) && (*oriented || !compatible(*target, *source)) {
error(
out,
r.id,
"exchange_allocation_performer",
"endpoints",
"Actual activity allocations must agree with one permitted communication direction; review the means or the activity allocations.",
);
} else if actual.0.is_none() || actual.1.is_none() {
warning(
out,
r.id,
"incomplete_exchange_allocation",
"endpoints",
"Add or review actual activity-to-participant Allocations; role responsibility alone does not resolve communication allocation.",
);
}
if !items_satisfy(p, work.body.items(), boundary.body.items()) {
error(
out,
r.id,
"exchange_allocation_items",
"items",
"Communication means must declare every allocated interaction item; review the carried item contract explicitly.",
);
}
return;
}
let RelationBody::BoundaryExchange {
source: a,
target: b,
@@ -1229,7 +1261,11 @@ pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut
r.id,
"legacy_exchange_allocation",
"endpoints",
"Resolve legacy ports and explicitly allocate the corresponding typed ports.",
if p.layer_of(boundary.id) == Some(Layer::Oa) {
"This preserved OA boundary uses legacy ports; review its communication representation without inventing SA port contracts."
} else {
"Resolve legacy ports and explicitly allocate the corresponding typed ports."
},
);
for (function, port) in [(work.source(), *a), (work.target(), *b)] {
match (actual_performer(p, function), owner(port)) {
+110 -9
View File
@@ -226,8 +226,58 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
};
project_diagram(p, d)
}
/// Reuse an index built for this accepted semantic revision during presentation
/// previews. Callers must invalidate it when semantic records change.
pub fn project_view_with_index(
p: &Project,
diagram: Id,
index: &crate::view_options::ViewIndex,
) -> ProjectedView {
p.diagrams
.get(&diagram)
.map_or_else(ProjectedView::default, |d| {
project_diagram_with_index(p, d, index)
})
}
/// Project alternate placement geometry without copying or mutating semantics.
pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
let index = crate::view_options::ViewIndex::new(p);
project_diagram_with_index(p, d, &index)
}
pub fn project_diagram_with_index(
p: &Project,
d: &Diagram,
index: &crate::view_options::ViewIndex,
) -> ProjectedView {
project_diagram_impl(p, d, None, Some(index))
}
/// Unfiltered eligibility from the same projection/anchor/fold decisions used
/// for drawing. Compute once for a view-options editor, then combine with the
/// prepared filter's reasons; hidden lines remain geometrically eligible here.
pub fn edge_availability(
p: &Project,
d: &Diagram,
) -> BTreeMap<Id, crate::view_options::EdgeAvailability> {
let mut availability = p
.relations
.keys()
.chain(p.traces.keys())
.map(|id| {
(
*id,
crate::view_options::EdgeAvailability::OutsideProjection,
)
})
.collect();
project_diagram_impl(p, d, Some(&mut availability), None);
availability
}
fn project_diagram_impl(
p: &Project,
d: &Diagram,
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
index: Option<&crate::view_options::ViewIndex>,
) -> ProjectedView {
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
@@ -335,6 +385,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
.collect();
let mut edges = Vec::new();
for r in p.relations.values() {
// Interface allocations are selectable nested dictionary rows. Their
// independently identified contracts do not add noisy diagram arrows.
if r.kind() == RelationKind::InterfaceItemAllocation {
continue;
}
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
continue;
}
@@ -349,6 +404,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
),
ProjectionKind::FunctionalDataflow => matches!(
r.kind(),
@@ -362,19 +418,36 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
| RelationKind::MissionCapability
| RelationKind::MissionInvolvement
| RelationKind::CapabilityInvolvement
| RelationKind::CommunicationMean
),
_ => true,
};
if !included {
continue;
}
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& (source != target
|| (d.projection.state_machine().is_some()
&& r.source() == r.target()
&& nodes
.iter()
.any(|n| n.id == source && n.element == r.source())))
let ends = (anchor(r.source()), anchor(r.target()));
let self_loop = (d.projection.state_machine().is_some()
|| r.kind() == RelationKind::DataAssociation)
&& r.source() == r.target()
&& nodes
.iter()
.any(|n| Some(n.id) == ends.0 && n.element == r.source());
if let Some(availability) = availability.as_deref_mut() {
use crate::view_options::EdgeAvailability as A;
availability.insert(
r.id,
match ends {
(Some(a), Some(b)) if a != b || self_loop => A::Visible,
(Some(_), Some(_)) => A::FoldedInternal,
(a, b) => A::EndpointNotShown {
source: a.is_none(),
target: b.is_none(),
},
},
);
}
if let (Some(source), Some(target)) = ends
&& (source != target || self_loop)
{
let (source_port, target_port) = match &r.body {
RelationBody::FunctionalExchange {
@@ -396,7 +469,10 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationBody::BoundaryExchange {
oriented: false,
..
}
} | RelationBody::CommunicationMean {
oriented: false,
..
} | RelationBody::DataAssociation { .. }
),
source_port: source_port.filter(|id| visible_ports.contains(id)),
target_port: target_port.filter(|id| visible_ports.contains(id)),
@@ -405,7 +481,22 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
if d.projection == ProjectionKind::Traceability {
for t in p.traces.values() {
if let (Some(source), Some(target)) = (anchor(t.oa), anchor(t.sa))
let ends = (anchor(t.oa), anchor(t.sa));
if let Some(availability) = availability.as_deref_mut() {
use crate::view_options::EdgeAvailability as A;
availability.insert(
t.id,
match ends {
(Some(a), Some(b)) if a != b => A::Visible,
(Some(_), Some(_)) => A::FoldedInternal,
(a, b) => A::EndpointNotShown {
source: a.is_none(),
target: b.is_none(),
},
},
);
}
if let (Some(source), Some(target)) = ends
&& source != target
{
edges.push(ProjectedEdge {
@@ -427,6 +518,16 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
} else {
port_layout::layout(p, d, &mut nodes, &edges)
};
// Port geometry uses every eligible edge. Presentation filters must never
// move visible ports when another line is hidden or a category is selected.
if let Some(index) = index {
let filter = if d.projection == ProjectionKind::Traceability {
index.prepare_cross_layer(d.layer, &d.view_options)
} else {
index.prepare(d.layer, &d.view_options)
};
edges.retain(|edge| filter.includes(edge.id));
}
ProjectedView {
nodes,
edges,
+6 -1
View File
@@ -53,7 +53,12 @@ pub fn state_header_height(p: &Project, element: Id) -> f32 {
if *source == element)
})
.count();
64. + if actions { 28. } else { 0. } + internal as f32 * 56.
let typed_actions = p
.relations
.values()
.filter(|r| r.kind() == RelationKind::StateAction && r.source() == element)
.count();
64. + if actions { 28. } else { 0. } + typed_actions as f32 * 28. + internal as f32 * 56.
}
pub(crate) fn node_size(p: &Project, e: &Element, size: Size, collapsed: bool) -> Size {
+100 -13
View File
@@ -29,12 +29,22 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::ExchangeCategory { exchanges, .. } => {
for id in exchanges {
visit(id);
}
}
SharedBody::DataField {
type_ref: TypeRef::Named(id),
..
} => visit(id),
SharedBody::Interface { items } => {
for id in items {
SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => {
if let Some(id) = supertype {
visit(id);
}
}
@@ -77,12 +87,22 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
receiver,
reply_to,
values,
operation,
..
} => {
if let Some(id) = operation
.as_mut()
.and_then(|operation| operation.allocation.as_mut())
{
visit(id);
}
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
visit(id);
}
for value in values {
for id in &mut value.path {
visit(id);
}
visit(&mut value.field);
}
}
@@ -137,6 +157,22 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
visit(&mut target);
r.body.set_endpoints(source, target);
match &mut r.body {
RelationBody::ContextualLink {
owner,
source,
target,
purpose,
} => {
visit(owner);
for id in source.references.iter_mut().chain(&mut target.references) {
visit(id);
}
match purpose {
ContextualLinkPurpose::Exchange { exchange } => visit(exchange),
ContextualLinkPurpose::CommonWork { work } => visit(work),
ContextualLinkPurpose::Precedence { .. } => {}
}
}
RelationBody::StateTransition {
triggers, effects, ..
} => {
@@ -144,7 +180,9 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
visit(id);
}
}
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. } => {
for id in items {
visit(id);
}
@@ -181,6 +219,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
for d in p.diagrams.values_mut() {
d.view_options.visit_references(|id| {
if *id == old {
*id = new;
}
});
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
@@ -214,6 +257,13 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
if t.sa == old {
t.sa = new;
}
if let Some(baseline) = t.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
crate::semantic::visit_semantic_references(baseline, |id| {
if *id == old {
*id = new;
}
});
}
}
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
@@ -247,6 +297,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
});
if removed.contains(&r.source())
|| removed.contains(&r.target())
|| (r.kind() == RelationKind::ContextualLink
&& relation_references(r).iter().any(|id| removed.contains(id)))
|| port_removed
|| work_allocation_removed
{
@@ -359,6 +411,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
p.traces.retain(|id, _| !removed.contains(id));
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.view_options.retain_references(|id| !removed.contains(id));
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
@@ -377,7 +430,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => clear(supertype),
SharedBody::Event {
body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)),
@@ -397,7 +455,11 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
SharedBody::Scenario { context, .. } => clear(context),
SharedBody::Lifeline { represented } => clear(represented),
SharedBody::Constraint { constrained, .. } => {
SharedBody::ExchangeCategory {
exchanges: constrained,
..
}
| SharedBody::Constraint { constrained, .. } => {
constrained.retain(|id| !removed.contains(id))
}
SharedBody::Occurrence { body, .. } => match body {
@@ -407,13 +469,20 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
receiver,
reply_to,
values,
operation,
..
} => {
if let Some(operation) = operation {
clear(&mut operation.allocation);
}
clear(exchange);
clear(sender);
clear(receiver);
clear(reply_to);
values.retain(|value| !removed.contains(&value.field));
values.retain(|value| {
!removed.contains(&value.field)
&& value.path.iter().all(|id| !removed.contains(id))
});
}
OccurrenceBody::Action { action, lifeline } => {
clear(action);
@@ -464,7 +533,8 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
}
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. } => {
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. } => {
items.retain(|id| !removed.contains(id))
}
RelationBody::FunctionalExchange { items, .. } => {
@@ -485,20 +555,37 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
exchange: Some(id), ..
},
..
}) => p
.relations
.get(id)
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
}) => p.relations.get(id).map(|r| {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = e.shared()
else {
unreachable!()
};
let retained: Vec<_> = values
.iter()
.filter(|value| {
crate::data::carried_value_path(p, r, &value.path, value.field).is_some()
|| (value.path.is_empty()
&& crate::data::carried_field_paths(p, r)
.iter()
.any(|f| f.field == value.field))
})
.cloned()
.collect();
(e.id, retained)
}),
_ => None,
})
.collect();
for (id, fields) in allowed {
for (id, retained) in allowed {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
{
values.retain(|value| fields.contains(&value.field));
*values = retained;
}
}
}
+148 -4
View File
@@ -1,7 +1,7 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -55,6 +55,122 @@ pub struct EdgeLabelGeometry {
pub bounds: RoutingBounds,
}
/// Derived labels and navigation markers for the two named association ends.
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
#[derive(Debug, Clone, PartialEq)]
pub struct AssociationEndGeometry {
pub source_end: bool,
pub label: EdgeLabelGeometry,
pub tip: Position,
pub inward: Position,
pub navigable: bool,
}
pub fn association_end_geometry(
relation: &Relation,
routed: &RoutedEdge,
) -> Vec<AssociationEndGeometry> {
let RelationBody::DataAssociation {
source_navigable,
target_navigable,
..
} = &relation.body
else {
return Vec::new();
};
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
let Some(tip) = (if source_end {
routed.points.first()
} else {
routed.points.last()
})
.copied() else {
continue;
};
let inward = if source_end {
routed.points.iter().find(|p| **p != tip)
} else {
routed.points.iter().rev().find(|p| **p != tip)
};
let Some(inward) = inward.copied() else {
continue;
};
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
let role = if role.chars().count() > 32 {
role.chars()
.take(31)
.chain(std::iter::once('…'))
.collect::<String>()
} else {
role.to_owned()
};
format!("{role} [{multiplicity}")
} else {
full
};
let size = Size {
width: text.chars().count() as f32 * 7.0 + 12.0,
height: 22.0,
};
let dx = inward.x - tip.x;
let dy = inward.y - tip.y;
let length = dx.hypot(dy);
let direction = pos(dx / length, dy / length);
// One normal sign puts opposite endpoints of a straight edge on
// opposite sides because their inward tangent directions are reversed.
let along =
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
let normal =
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
let mut center = pos(
tip.x + direction.x * along - direction.y * normal,
tip.y + direction.y * along + direction.x * normal,
);
if let Some(other) = ends.first()
&& (center.x - other.label.center.x).abs()
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
&& (center.y - other.label.center.y).abs()
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
{
// Same-side anchors on a self-loop can still be close. Separate
// their complete bounds along this end's normal in one step.
let perpendicular = pos(-direction.y, direction.x);
let distance = (center.x - other.label.center.x) * perpendicular.x
+ (center.y - other.label.center.y) * perpendicular.y;
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
/ 2.0
+ 8.0;
let shift = if distance < 0.0 {
-clearance - distance
} else {
clearance - distance
};
center.x += perpendicular.x * shift;
center.y += perpendicular.y * shift;
}
ends.push(AssociationEndGeometry {
source_end,
label: EdgeLabelGeometry {
text,
fraction: if source_end { 0.0 } else { 1.0 },
anchor: tip,
center,
bounds: RoutingBounds {
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
size,
},
},
tip,
inward,
navigable,
});
}
ends
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
PinInsideObstacle { pin: usize, obstacle: Id },
@@ -466,6 +582,14 @@ fn route_diagram_impl(
// never changes paths or authored metadata. Previews retain other cached
// labels and avoid the paths actually shown during the gesture.
let mut previous_labels = Vec::new();
for routed in edges.values() {
if let Some(relation) = project.relations.get(&routed.id) {
for end in association_end_geometry(relation, routed) {
bounds.rect(end.label.bounds.position, end.label.bounds.size);
previous_labels.push(end.label.bounds);
}
}
}
for edge in ordered {
let Some(routed) = edges.get_mut(&edge.id) else {
continue;
@@ -483,6 +607,7 @@ fn route_diagram_impl(
.flatten();
routed.label = make_label(
project,
diagram.view_options.exchange_labels,
edge.id,
&routed.points,
authored_label,
@@ -579,7 +704,7 @@ fn route_edge(
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-transition must leave and return at distinct anchors.
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
@@ -1107,7 +1232,9 @@ fn label_text(project: &Project, edge: Id) -> String {
let text = match relation.kind() {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange => relation.name.as_str(),
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
| RelationKind::DataAssociation => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»",
RelationKind::CapabilityGeneralization => "specializes",
@@ -1137,8 +1264,11 @@ fn empty_label() -> EdgeLabelGeometry {
}
}
// Keep semantic label inputs and the independent collision sets explicit.
#[allow(clippy::too_many_arguments)]
fn make_label(
project: &Project,
labels: crate::view_options::ExchangeLabels,
edge: Id,
points: &[Position],
authored: Option<&crate::EdgeLabel>,
@@ -1146,7 +1276,21 @@ fn make_label(
previous_labels: &[RoutingBounds],
strokes: &[Segment],
) -> EdgeLabelGeometry {
let text = label_text(project, edge);
let text = if labels != crate::view_options::ExchangeLabels::Name
&& project
.relations
.get(&edge)
.is_some_and(|r| crate::view_options::exchange_family(r.kind()).is_some())
{
let label = labels.label(project, edge);
if label.chars().count() > 48 {
label.chars().take(47).chain(std::iter::once('…')).collect()
} else {
label
}
} else {
label_text(project, edge)
};
let size = Size {
width: if text.is_empty() {
0.0
+16
View File
@@ -0,0 +1,16 @@
//! Closed typed facts for deliberately reviewed OA/SA semantics.
//!
//! Historical facts are independently acknowledged observations, not a synthetic
//! live model. Their UUIDs are not live references and survive ordinary deletion.
mod catalog;
mod historical;
mod linked;
mod mutate;
mod review;
mod types;
pub use catalog::*;
pub(crate) use historical::{HistoricalSourceKind, historical_source_slots};
pub use historical::{validate_semantic_baseline, visit_semantic_references};
pub use linked::*;
pub use review::*;
pub use types::*;
File diff suppressed because it is too large Load Diff
+430
View File
@@ -0,0 +1,430 @@
use super::types::*;
use crate::model::*;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HistoricalSourceKind {
Reference,
Enum,
}
const MAX_FACTS: usize = 16_384;
const MAX_LEAVES: usize = 1_000_000;
const MAX_PATH: usize = 128;
struct Walk<'a> {
path: Vec<String>,
slots: Vec<(Vec<String>, HistoricalSourceKind)>,
errors: Vec<String>,
leaves: usize,
visitor: &'a mut dyn FnMut(&mut Id),
}
impl Walk<'_> {
fn at(&mut self, key: impl ToString, f: impl FnOnce(&mut Self)) {
if self.leaves >= MAX_LEAVES {
return;
}
self.leaves += 1;
self.path.push(key.to_string());
f(self);
self.path.pop();
}
fn slot(&mut self, kind: HistoricalSourceKind) {
self.slots.push((self.path.clone(), kind));
}
fn error(&mut self, message: &str) {
self.errors
.push(format!("{}: {message}", self.path.join(".")));
}
}
trait Historical {
fn walk(&mut self, w: &mut Walk<'_>);
}
impl Historical for Id {
fn walk(&mut self, w: &mut Walk<'_>) {
w.slot(HistoricalSourceKind::Reference);
(w.visitor)(self);
}
}
macro_rules! plain {
($($ty:ty),*) => { $( impl Historical for $ty { fn walk(&mut self, _: &mut Walk<'_>) {} } )* };
}
plain!(bool, u32, i64);
impl Historical for String {
fn walk(&mut self, w: &mut Walk<'_>) {
if !crate::validation::valid_text(self) {
w.error("historical text contains an invalid character");
}
if self.len() > 1_000_000 {
w.error("historical text work limit exceeded");
}
}
}
impl Historical for f64 {
fn walk(&mut self, w: &mut Walk<'_>) {
if !self.is_finite() {
w.error("historical real must be finite");
}
}
}
macro_rules! enums {
($($ty:ty),*) => { $( impl Historical for $ty {
fn walk(&mut self, w: &mut Walk<'_>) { w.slot(HistoricalSourceKind::Enum); }
} )* };
}
enums!(
crate::view_options::ExchangeFamily,
ExchangeItemKind,
ExchangeMechanism,
ParameterDirection,
FieldStorage,
ComponentPortKind,
ComponentPortOrientation,
PortDirection,
ScenarioViewpoint,
ControlKind,
FragmentOperator,
TimeEventKind,
StateKind,
TransmissionProtocol,
AcquisitionProtocol,
TransitionKind,
StateActionKind,
ArtifactRelation,
ReferenceCollection,
OrderedCollection,
IncidentEnd,
DataConstructor,
PortConstructor,
OccurrenceConstructor,
EventConstructor,
MessageConstructor
);
impl<T: Historical> Historical for Option<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if let Some(v) = self {
v.walk(w);
}
}
}
impl<T: Historical> Historical for Vec<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
for (i, v) in self.iter_mut().enumerate().take(MAX_LEAVES) {
w.at(i, |w| v.walk(w));
}
if self.len() > MAX_LEAVES {
w.error("historical collection work limit exceeded");
}
}
}
impl<T: Historical> Historical for Observed<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::NotApplicable => w.slot(HistoricalSourceKind::Enum),
Self::Value(v) => w.at("Value", |w| v.walk(w)),
}
}
}
impl<T: Historical> Historical for Reviewed<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if matches!(
(&self.oa, &self.sa),
(Observed::NotApplicable, Observed::NotApplicable)
) {
w.error("a reviewed facet must apply on at least one side");
}
w.at("oa", |w| self.oa.walk(w));
w.at("sa", |w| self.sa.walk(w));
}
}
impl<T: Historical> Historical for KeyPair<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.oa.is_none() && self.sa.is_none() {
w.error("key pair cannot be empty on both sides");
}
w.at("oa", |w| self.oa.walk(w));
w.at("sa", |w| self.sa.walk(w));
}
}
impl Historical for AnnotationKey {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.path.len() > MAX_PATH {
w.error("historical annotation path limit exceeded");
}
w.at("path", |w| self.path.walk(w));
w.at("field", |w| self.field.walk(w));
}
}
impl Historical for MessageValue {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.path.len() > MAX_PATH {
w.error("historical annotation path limit exceeded");
}
w.at("path", |w| self.path.walk(w));
w.at("field", |w| self.field.walk(w));
w.at("expression", |w| self.expression.walk(w));
}
}
impl Historical for LifelineBinding {
fn walk(&mut self, w: &mut Walk<'_>) {
w.at("referenced", |w| self.referenced.walk(w));
w.at("local", |w| self.local.walk(w));
}
}
impl Historical for OperationUse {
fn walk(&mut self, w: &mut Walk<'_>) {
w.at("allocation", |w| self.allocation.walk(w));
}
}
impl Historical for OccurrenceEndpoint {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.references.len() > crate::topology_context::MAX_REFERENCE_DEPTH {
w.error("historical contextual path depth exceeded");
}
w.at("references", |w| self.references.walk(w));
w.at("occurrence", |w| self.occurrence.walk(w));
}
}
impl Historical for ContextualLinkPurpose {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Precedence { guard } => w.at("Precedence", |w| w.at("guard", |w| guard.walk(w))),
Self::Exchange { exchange } => {
w.at("Exchange", |w| w.at("exchange", |w| exchange.walk(w)))
}
Self::CommonWork { work } => w.at("CommonWork", |w| w.at("work", |w| work.walk(w))),
}
}
}
impl Historical for DataValue {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Real(value) => w.at("Real", |w| value.walk(w)),
Self::String(value) => w.at("String", |w| value.walk(w)),
Self::Enumeration(value) => w.at("Enumeration", |w| value.walk(w)),
Self::Boolean(_) | Self::Integer(_) => {}
}
}
}
impl Historical for TypeRef {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Named(id) => w.at("Named", |w| id.walk(w)),
_ => w.slot(HistoricalSourceKind::Enum),
}
}
}
impl Historical for ArtifactReview {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Presence(v) => w.at("Presence", |w| v.walk(w)),
Self::Name(v) => w.at("Name", |w| v.walk(w)),
Self::Description(v) => w.at("Description", |w| v.walk(w)),
Self::Owner(v) => w.at("Owner", |w| v.walk(w)),
Self::Relation(v) => w.at("Relation", |w| v.walk(w)),
Self::Uri(v) => w.at("Uri", |w| v.walk(w)),
Self::Revision(v) => w.at("Revision", |w| v.walk(w)),
Self::Fragment(v) => w.at("Fragment", |w| v.walk(w)),
}
}
}
macro_rules! family_visitor {
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
impl Historical for FacetReview {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
$( Self::$family(v) => w.at(stringify!($family), |w| match v {
$( $review::$leaf(v) => w.at(stringify!($leaf), |w| v.walk(w)), )*
}), )*
Self::Member { collection, keys, review } => w.at("Member", |w| {
w.at("collection", |w| collection.walk(w)); w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Order { collection, review } => w.at("Order", |w| {
w.at("collection", |w| collection.walk(w)); w.at("review", |w| review.walk(w));
for side in [&review.oa, &review.sa] { if let Observed::Value(order) = side {
if order.iter().collect::<BTreeSet<_>>().len() != order.len() { w.error("duplicate historical order member"); }
}}
}),
Self::Literal { keys, review } => w.at("Literal", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Annotation { keys, review } => w.at("Annotation", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
if let Observed::Value(Some(row)) = value {
if key.as_ref() != Some(&AnnotationKey::from(row)) { w.error("historical annotation key and payload disagree"); }
}
}
}),
Self::Binding { keys, review } => w.at("Binding", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
if let Observed::Value(Some(row)) = value {
if *key != Some(row.referenced) { w.error("historical binding key and payload disagree"); }
}
}
}),
Self::Child { keys, review } => w.at("Child", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Incident { keys, end, review } => w.at("Incident", |w| {
w.at("keys", |w| keys.walk(w)); w.at("end", |w| end.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Artifact { keys, review } => w.at("Artifact", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
}
}
}
};
}
scalar_families!(family_visitor);
fn walk_baseline<'a>(
baseline: &mut SemanticBaseline,
visitor: &'a mut dyn FnMut(&mut Id),
) -> Walk<'a> {
let mut walk = Walk {
path: vec!["facets".into()],
slots: vec![],
errors: vec![],
leaves: 0,
visitor,
};
if baseline.facets.len() > MAX_FACTS {
walk.error("historical facet count limit exceeded");
}
for (i, facet) in baseline.facets.iter_mut().enumerate().take(MAX_FACTS) {
walk.at(i, |w| facet.walk(w));
}
if walk.leaves >= MAX_LEAVES {
walk.error("historical reference work limit exceeded");
}
walk
}
/// Historical UUIDs are visited only on explicit identity remapping, never deletion.
pub fn visit_semantic_references(
baseline: &mut SemanticBaseline,
mut visitor: impl FnMut(&mut Id),
) {
walk_baseline(baseline, &mut visitor);
}
pub(crate) fn historical_source_slots(
baseline: &SemanticBaseline,
) -> Vec<(Vec<String>, HistoricalSourceKind)> {
walk_baseline(&mut baseline.clone(), &mut |_| {}).slots
}
/// Checks persisted facts without demanding that historical references still exist.
pub fn validate_semantic_baseline(baseline: &SemanticBaseline) -> Result<(), Vec<String>> {
let mut errors = walk_baseline(&mut baseline.clone(), &mut |_| {}).errors;
let mut slots = BTreeSet::new();
for selector in baseline
.facets
.iter()
.take(MAX_FACTS)
.map(FacetReview::selector)
{
if selector_slots(&selector)
.into_iter()
.any(|slot| !slots.insert(slot))
{
errors.push(format!(
"duplicate or overlapping historical facet: {selector:?}"
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub(super) fn selector_slots(selector: &FacetSelector) -> Vec<FacetSelector> {
fn split<T: Clone>(keys: &KeyPair<T>) -> Vec<KeyPair<T>> {
let mut result = vec![];
if let Some(oa) = &keys.oa {
result.push(KeyPair {
oa: Some(oa.clone()),
sa: None,
});
}
if let Some(sa) = &keys.sa {
result.push(KeyPair {
oa: None,
sa: Some(sa.clone()),
});
}
result
}
match selector {
FacetSelector::Member { collection, keys } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Member {
collection: *collection,
keys,
})
.collect(),
FacetSelector::Literal(keys) => split(keys)
.into_iter()
.map(FacetSelector::Literal)
.collect(),
FacetSelector::Annotation(keys) => split(keys)
.into_iter()
.map(FacetSelector::Annotation)
.collect(),
FacetSelector::Binding(keys) => split(keys)
.into_iter()
.map(FacetSelector::Binding)
.collect(),
FacetSelector::Child(keys) => split(keys).into_iter().map(FacetSelector::Child).collect(),
FacetSelector::Incident { keys, end } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Incident { keys, end: *end })
.collect(),
FacetSelector::Artifact { keys, facet } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Artifact {
keys,
facet: *facet,
})
.collect(),
value => vec![value.clone()],
}
}
fn pair_overlap<T: Eq>(a: &KeyPair<T>, b: &KeyPair<T>) -> bool {
a.oa.as_ref()
.zip(b.oa.as_ref())
.is_some_and(|(a, b)| a == b)
|| a.sa
.as_ref()
.zip(b.sa.as_ref())
.is_some_and(|(a, b)| a == b)
}
/// Slot identity is each actual side's key, not equality of the complete pair.
pub(crate) fn keyed_slots_overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
match (a, b) {
(
FacetSelector::Member {
collection: a,
keys: ak,
},
FacetSelector::Member {
collection: b,
keys: bk,
},
) => a == b && pair_overlap(ak, bk),
(FacetSelector::Literal(a), FacetSelector::Literal(b)) => pair_overlap(a, b),
(FacetSelector::Annotation(a), FacetSelector::Annotation(b)) => pair_overlap(a, b),
(FacetSelector::Binding(a), FacetSelector::Binding(b)) => pair_overlap(a, b),
(FacetSelector::Child(a), FacetSelector::Child(b)) => pair_overlap(a, b),
(
FacetSelector::Incident { keys: ak, end: a },
FacetSelector::Incident { keys: bk, end: b },
) => a == b && pair_overlap(ak, bk),
(
FacetSelector::Artifact { keys: ak, facet: a },
FacetSelector::Artifact { keys: bk, facet: b },
) => a == b && pair_overlap(ak, bk),
_ => a == b,
}
}
+174
View File
@@ -0,0 +1,174 @@
use super::*;
use crate::model::*;
use crate::transitions::Drift;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LinkedSemanticImpact {
pub objects: KeyPair<Id>,
pub traces: Vec<Id>,
pub drift: Drift,
pub unreviewed: bool,
}
const MAX_LINKED_OBJECTS: usize = 4_096;
const MAX_LINKED_EDGES: usize = 32_768;
/// Derived dependency diagnostics, never another historical baseline. Follow
/// direct references/children, then only reusable dictionary/event definitions.
/// Association endpoints and ordinary architecture neighborhoods do not expand.
pub fn linked_semantic_impacts(
project: &Project,
trace: &Trace,
references: &BTreeMap<Id, Id>,
) -> Result<Vec<LinkedSemanticImpact>, String> {
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for element in project.elements.values() {
if let Some(parent) = element.parent {
children.entry(parent).or_default().push(element.id);
}
}
let mut contracts: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for relation in project.relations.values() {
if let RelationBody::InterfaceItemAllocation { source, .. } = relation.body {
contracts.entry(source).or_default().push(relation.id);
}
}
let direct = |id| -> Vec<Id> {
let mut out = vec![];
if let Some(element) = project.elements.get(&id) {
out.extend(
crate::references::element_references(element)
.into_iter()
.filter(|r| Some(*r) != element.parent),
);
}
if let Some(relation) = project.relations.get(&id) {
out.extend(crate::references::relation_references(relation));
}
out.extend(children.get(&id).into_iter().flatten().copied());
out.extend(contracts.get(&id).into_iter().flatten().copied());
out
};
let expandable = |id| {
project.elements.get(&id).is_some_and(|e| {
matches!(
e.kind(),
Kind::DataType
| Kind::DataField
| Kind::ExchangeItem
| Kind::Interface
| Kind::Event
)
}) || project
.relations
.get(&id)
.is_some_and(|r| matches!(r.body, RelationBody::InterfaceItemAllocation { .. }))
};
let mut edges = 0usize;
let mut traverse = |root| -> Result<BTreeSet<Id>, String> {
let mut seen = BTreeSet::from([root]);
let first = direct(root);
edges += first.len();
if edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope".into(),
);
}
let mut queue: VecDeque<_> = first.into();
while let Some(id) = queue.pop_front() {
if !seen.insert(id) {
continue;
}
if seen.len() > MAX_LINKED_OBJECTS || edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope"
.into(),
);
}
if expandable(id) {
let next = direct(id);
edges += next.len();
if edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope"
.into(),
);
}
queue.extend(next);
}
}
seen.remove(&root);
Ok(seen)
};
let oa = traverse(trace.oa)?;
let sa = traverse(trace.sa)?;
let mut traced: BTreeMap<(Id, Id), Vec<&Trace>> = BTreeMap::new();
for t in project.traces.values() {
traced.entry((t.oa, t.sa)).or_default().push(t);
}
let mut pairs = vec![];
let mut used = BTreeSet::new();
for id in oa {
let target = references.get(&id).copied();
if let Some(id) = target {
used.insert(id);
}
pairs.push(KeyPair {
oa: Some(id),
sa: target,
});
}
for id in sa.difference(&used) {
pairs.push(KeyPair {
oa: None,
sa: Some(*id),
});
}
let mut result = vec![];
for objects in pairs {
if objects.oa == Some(trace.oa) && objects.sa == Some(trace.sa) {
continue;
}
let links = objects
.oa
.zip(objects.sa)
.and_then(|pair| traced.get(&pair));
let mut flags = (false, false, links.is_none());
let mut ids = vec![];
for linked in links.into_iter().flatten() {
ids.push(linked.id);
let facts = super::catalog::own_semantic_facets(project, linked, references)?;
if facts.is_empty() {
flags.2 = true;
}
for fact in facts {
match fact.drift {
Drift::OaChanged => flags.0 = true,
Drift::SaChanged => flags.1 = true,
Drift::BothChanged => {
flags.0 = true;
flags.1 = true;
}
Drift::Unreviewed => flags.2 = true,
_ => {}
}
}
}
let drift = match flags {
(true, true, _) => Drift::BothChanged,
(true, false, _) => Drift::OaChanged,
(false, true, _) => Drift::SaChanged,
(false, false, true) => Drift::Unreviewed,
_ => Drift::Unchanged,
};
result.push(LinkedSemanticImpact {
objects,
traces: ids,
drift,
unreviewed: flags.2,
});
}
Ok(result)
}
+616
View File
@@ -0,0 +1,616 @@
use super::catalog::{collection, order_collection, sides, write_scalar};
use super::*;
use crate::model::*;
use crate::transitions::TransitionDirection;
use std::collections::{BTreeMap, BTreeSet};
pub(crate) fn set_collection(
p: &mut Project,
id: Id,
which: ReferenceCollection,
list: Vec<Id>,
) -> Result<(), String> {
if list.iter().collect::<BTreeSet<_>>().len() != list.len() {
return Err("Collection contains duplicate members".into());
}
use ReferenceCollection as C;
if which == C::Steps {
let e = p.elements.get_mut(&id).ok_or("Missing sequence")?;
if let Some(steps) = e.steps_mut() {
*steps = list;
return Ok(());
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { occurrences, .. },
..
}) = e.shared_mut()
{
*occurrences = list;
return Ok(());
}
return Err("Target has no ordered steps".into());
}
let slot = match which {
C::Steps => unreachable!(),
C::FragmentBranches => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => Some(branches),
_ => None,
},
C::FunctionInputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
}) => Some(incoming_items),
_ => None,
},
C::FunctionOutputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) => Some(outgoing_items),
_ => None,
},
C::SignalItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Event {
body: EventBody::Signal { items },
}) => Some(items),
_ => None,
},
C::ExchangeItems => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(
RelationBody::Interaction { items, .. }
| RelationBody::FunctionalExchange { items, .. }
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. },
) => Some(items),
_ => None,
},
C::TransitionTriggers => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers),
_ => None,
},
C::TransitionEffects => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(RelationBody::StateTransition { effects, .. }) => Some(effects),
_ => None,
},
C::Constrained => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
_ => None,
},
C::CategoryExchanges => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges),
_ => None,
},
}
.ok_or("Collection is inapplicable on target")?;
*slot = list;
Ok(())
}
pub(crate) fn chosen<T>(pair: &KeyPair<T>, layer: Layer) -> Option<&T> {
match layer {
Layer::Oa => pair.oa.as_ref(),
Layer::Sa => pair.sa.as_ref(),
}
}
pub(crate) fn value<T>(review: &Reviewed<T>, layer: Layer) -> Result<&T, String> {
match match layer {
Layer::Oa => &review.oa,
Layer::Sa => &review.sa,
} {
Observed::Value(v) => Ok(v),
Observed::NotApplicable => {
Err("Facet does not exist in the selected constructor branch".into())
}
}
}
pub(crate) fn directional_map(
map: &BTreeMap<Id, Id>,
direction: TransitionDirection,
) -> Result<BTreeMap<Id, Id>, String> {
if direction == TransitionDirection::OaToSa {
return Ok(map.clone());
}
let mut inverse = BTreeMap::new();
for (oa, sa) in map {
if inverse.insert(*sa, *oa).is_some_and(|old| old != *oa) {
return Err(format!(
"Reverse reference {sa} has multiple OA correspondences; supply an unambiguous mapping"
));
}
}
Ok(inverse)
}
pub(crate) fn remap_record(
record: &mut SemanticRecord,
map: &BTreeMap<Id, Id>,
) -> Result<(), String> {
let mut missing = BTreeSet::new();
let mut remap = |id: &mut Id| {
if let Some(new) = map.get(id) {
*id = *new;
} else {
missing.insert(*id);
}
};
match record {
SemanticRecord::Element(e) => crate::references::visit_element_refs(e, &mut remap),
SemanticRecord::Relation(r) => crate::references::visit_relation_refs(r, &mut remap),
SemanticRecord::Artifact(a) => remap(&mut a.owner),
}
if missing.is_empty() {
Ok(())
} else {
Err(format!(
"Missing explicit reference correspondence: {missing:?}"
))
}
}
pub(crate) fn remap_fact(
fact: &FacetReview,
map: &BTreeMap<Id, Id>,
source: Layer,
) -> Result<FacetReview, String> {
// Remap only the selected observation. Keys remain in canonical OA/SA space.
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
let base = SemanticBaseline {
facets: vec![fact.clone()],
};
let side = match source {
Layer::Oa => "oa",
Layer::Sa => "sa",
};
let mut missing = BTreeSet::new();
for (path, kind) in historical_source_slots(&base) {
if kind != HistoricalSourceKind::Reference {
continue;
}
let rest = &path[2..];
if !rest.windows(2).any(|w| w[0] == side && w[1] == "Value") {
continue;
}
let mut leaf = &mut json;
for part in rest {
leaf = if leaf.is_array() {
leaf.get_mut(
part.parse::<usize>()
.map_err(|_| "Invalid historical array index")?,
)
} else {
leaf.get_mut(part)
}
.ok_or("Invalid historical source slot")?;
}
let id: Id = serde_json::from_value(leaf.clone()).map_err(|e| e.to_string())?;
if let Some(new) = map.get(&id) {
*leaf = serde_json::to_value(new).unwrap();
} else {
missing.insert(id);
}
}
if !missing.is_empty() {
return Err(format!(
"Missing explicit reference correspondence: {missing:?}"
));
}
serde_json::from_value(json).map_err(|e| e.to_string())
}
pub(crate) fn check_keys(selector: &FacetSelector, map: &BTreeMap<Id, Id>) -> Result<(), String> {
fn pair(keys: &KeyPair<Id>, map: &BTreeMap<Id, Id>) -> Result<(), String> {
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa)
&& map.get(&oa) != Some(&sa)
{
return Err("Key pair disagrees with frozen reference correspondence".into());
}
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty key pair".into());
}
Ok(())
}
match selector {
FacetSelector::Member { keys, .. }
| FacetSelector::Binding(keys)
| FacetSelector::Child(keys)
| FacetSelector::Incident { keys, .. }
| FacetSelector::Artifact { keys, .. } => pair(keys, map),
FacetSelector::Annotation(keys) => {
if let (Some(oa), Some(sa)) = (&keys.oa, &keys.sa) {
if oa.path.len() != sa.path.len() {
return Err("Annotation path pairing disagrees".into());
}
for (oa, sa) in oa
.path
.iter()
.chain([&oa.field])
.zip(sa.path.iter().chain([&sa.field]))
{
pair(
&KeyPair {
oa: Some(*oa),
sa: Some(*sa),
},
map,
)?;
}
}
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty annotation pair".into());
}
Ok(())
}
FacetSelector::Literal(keys) => {
if keys
.oa
.as_ref()
.zip(keys.sa.as_ref())
.is_some_and(|(oa, sa)| oa != sa)
{
return Err(
"Enum literal identity is exact text; renaming is removal/addition".into(),
);
}
Ok(())
}
_ => Ok(()),
}
}
/// Apply just one selected fact to an in-memory expected project. The caller
/// subsequently prepares ordinary Commands and checks every induced difference.
pub(crate) fn write_fact(
p: &mut Project,
target: Id,
fact: &FacetReview,
from: Layer,
target_layer: Layer,
preserve_target_only: bool,
) -> Result<(), String> {
match fact {
FacetReview::Member {
collection: which,
keys,
review,
} => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let present = *value(review, from)?;
let key = chosen(keys, target_layer)
.copied()
.ok_or("Map the target collection member explicitly")?;
let mut list = collection(p, target, *which).ok_or("Inapplicable target collection")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|id| *id != key);
}
set_collection(p, target, *which, list)
}
FacetReview::Order {
collection: which,
review,
} => {
let supplied = value(review, from)?.clone();
let current = collection(p, target, order_collection(*which))
.ok_or("Inapplicable target order")?;
let supplied_set: BTreeSet<_> = supplied.iter().copied().collect();
let current_set: BTreeSet<_> = current.iter().copied().collect();
if supplied_set != current_set {
if preserve_target_only
&& supplied_set.is_subset(&current_set)
&& current
.iter()
.filter(|id| supplied_set.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Order must contain every surviving target member exactly once; target-only insertion requires an explicit final order".into());
}
set_collection(p, target, order_collection(*which), supplied)
}
FacetReview::Literal { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let present = *value(review, from)?;
let key = chosen(keys, target_layer).ok_or("Missing literal key on target")?;
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target is not an enumeration".into());
};
if present {
if !literals.contains(key) {
literals.push(key.clone());
}
} else {
literals.retain(|v| v != key);
}
Ok(())
}
FacetReview::Annotation { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Missing target annotation key")?;
let row = value(review, from)?.clone();
if row.as_ref().is_some_and(|v| AnnotationKey::from(v) != *key) {
return Err(
"Annotation candidate changes its row key; review removal/addition".into(),
);
}
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target has no message annotations".into());
};
replace_keyed_row(values, row, |v| AnnotationKey::from(v) == *key);
Ok(())
}
FacetReview::Binding { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = *chosen(keys, target_layer).ok_or("Missing target binding key")?;
let row = value(review, from)?.clone();
if row.as_ref().is_some_and(|v| v.referenced != key) {
return Err(
"Binding candidate changes its row key; review removal/addition".into(),
);
}
let Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { bindings, .. },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target has no scenario bindings".into());
};
replace_keyed_row(bindings, row, |v| v.referenced == key);
Ok(())
}
FacetReview::Child { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map target child explicitly")?;
let e = p
.elements
.get_mut(key)
.ok_or("Target child does not exist; create it through transition mapping")?;
if *value(review, from)? {
e.parent = Some(target);
} else if e.parent == Some(target) {
e.parent = None;
}
Ok(())
}
FacetReview::Incident { keys, end, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map incident relation explicitly")?;
if !*value(review, from)? {
return Err("Unlinking an incident relation requires an explicit final endpoint through the relation trace".into());
}
let relation = p
.relations
.get_mut(key)
.ok_or("Mapped incident relation is absent")?;
let (a, b) = relation.body.endpoints();
match end {
IncidentEnd::Source => relation.body.set_endpoints(target, b),
IncidentEnd::Target => relation.body.set_endpoints(a, target),
}
Ok(())
}
FacetReview::Artifact { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map artifact explicitly")?;
if let ArtifactReview::Presence(review) = review {
if !*value(review, from)? {
p.artifact_links.remove(key);
return Ok(());
}
if p.artifact_links.get(key).is_some_and(|a| a.owner == target) {
return Ok(());
}
return Err("Artifact creation requires a complete typed candidate".into());
}
let a = p
.artifact_links
.get_mut(key)
.ok_or("Artifact candidate is absent")?;
if a.owner != target {
return Err(
"Artifact metadata can only be reviewed on its currently attached owner trace"
.into(),
);
}
match review {
ArtifactReview::Presence(_) => unreachable!(),
ArtifactReview::Name(r) => a.name = value(r, from)?.clone(),
ArtifactReview::Description(r) => a.description = value(r, from)?.clone(),
ArtifactReview::Owner(r) => a.owner = *value(r, from)?,
ArtifactReview::Relation(r) => a.relation = *value(r, from)?,
ArtifactReview::Uri(r) => a.uri = value(r, from)?.clone(),
ArtifactReview::Revision(r) => a.revision = value(r, from)?.clone(),
ArtifactReview::Fragment(r) => a.fragment = value(r, from)?.clone(),
}
Ok(())
}
_ => {
let (oa, sa) = sides(fact);
let side = match from {
Layer::Oa => oa,
Layer::Sa => sa,
};
let value = side
.get("Value")
.ok_or("Selected branch is not applicable")?
.clone();
write_scalar(p, target, &fact.selector(), value)
}
}
}
// Reviewing an unordered row does not authorize changing its authored position.
// Preserve surviving entries in place; only genuinely new rows append.
fn replace_keyed_row<T: PartialEq>(
rows: &mut Vec<T>,
row: Option<T>,
matches: impl FnMut(&T) -> bool,
) {
match (rows.iter().position(matches), row) {
(Some(index), Some(row)) => {
if rows[index] != row {
rows[index] = row;
}
}
(Some(index), None) => {
rows.remove(index);
}
(None, Some(row)) => rows.push(row),
(None, None) => {}
}
}
pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
use FacetSelector as F;
match group {
F::Data(DataFacet::Constructor) => {
matches!(facet,F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
}
F::Port(PortFacet::Constructor) => matches!(
facet,
F::Port(_)
| F::Member {
collection: ReferenceCollection::FunctionInputItems
| ReferenceCollection::FunctionOutputItems,
..
}
),
F::Occurrence(OccurrenceFacet::Constructor) => {
matches!(facet,F::Occurrence(f) if !matches!(f,OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|| matches!(
facet,
F::Annotation(_)
| F::Binding(_)
| F::Member {
collection: ReferenceCollection::Steps
| ReferenceCollection::FragmentBranches,
..
}
| F::Order(_)
)
}
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
facet,
F::Occurrence(
OccurrenceFacet::MessageKind
| OccurrenceFacet::TimerExpression
| OccurrenceFacet::Operation
)
),
F::Event(EventFacet::Constructor) => matches!(
facet,
F::Event(_)
| F::Member {
collection: ReferenceCollection::SignalItems,
..
}
),
F::State(StateFacet::Kind) => facet == group,
_ => false,
}
}
pub(crate) fn copy_constructor(
p: &mut Project,
target: Id,
source: &SemanticRecord,
group: &FacetSelector,
) -> Result<(), String> {
let SemanticRecord::Element(source) = source else {
return Err("Constructor source must be an element".into());
};
let target = p
.elements
.get_mut(&target)
.ok_or("Missing target element")?;
let a = source.shared().ok_or("Source has no shared constructor")?;
let b = target
.shared_mut()
.ok_or("Target has no shared constructor")?;
match (group, a, b) {
(
FacetSelector::Data(DataFacet::Constructor),
SharedBody::DataType {
definition,
default,
..
},
SharedBody::DataType {
definition: d,
default: v,
..
},
) => {
*d = definition.clone();
*v = default.clone();
}
(
FacetSelector::Port(PortFacet::Constructor),
SharedBody::Port { contract, legacy },
SharedBody::Port {
contract: c,
legacy: l,
},
) => {
*c = contract.clone();
*l = legacy.clone();
}
(
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
SharedBody::Occurrence { body, .. },
SharedBody::Occurrence { body: b, .. },
) => *b = body.clone(),
(
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
},
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: k, ..
},
..
},
) => *k = message_kind.clone(),
(
FacetSelector::Event(EventFacet::Constructor),
SharedBody::Event { body },
SharedBody::Event { body: b },
) => *b = body.clone(),
(
FacetSelector::State(StateFacet::Kind),
SharedBody::State { kind, .. },
SharedBody::State { kind: k, .. },
) => *k = *kind,
_ => return Err("Constructor family mismatch".into()),
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
use crate::model::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Observed<T> {
NotApplicable,
Value(T),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reviewed<T> {
pub oa: Observed<T>,
pub sa: Observed<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyPair<K> {
pub oa: Option<K>,
pub sa: Option<K>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AnnotationKey {
pub path: Vec<Id>,
pub field: Id,
}
impl From<&MessageValue> for AnnotationKey {
fn from(value: &MessageValue) -> Self {
Self {
path: value.path.clone(),
field: value.field,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataConstructor {
Boolean,
Integer,
Real,
String,
Enumeration,
Record,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortConstructor {
FunctionInput,
FunctionOutput,
Component,
Legacy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OccurrenceConstructor {
Exchange,
Action,
Control,
SequenceReference,
ScenarioUse,
State,
Fragment,
Branch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventConstructor {
Signal,
Time,
Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageConstructor {
Asynchronous,
Call,
Reply,
Create,
Delete,
Timer,
}
// This finite catalog is also consumed by the typed historical leaf visitor.
// Each selector fixes its payload type; there is no arbitrary value/path schema.
macro_rules! scalar_families {
($m:ident) => { $m! {
Header, HeaderFacet, HeaderReview {
Name: String, Description: String, Parent: Option<Id>
}
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
}
Category, CategoryFacet, CategoryReview { Family: crate::view_options::ExchangeFamily }
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
Data, DataFacet, DataReview {
Constructor: DataConstructor,
IntegerMinimum: Option<i64>, IntegerMaximum: Option<i64>,
RealMinimum: Option<f64>, RealMaximum: Option<f64>,
StringMinimumLength: u32, StringMaximumLength: Option<u32>,
RecordSupertype: Option<Id>, Default: Option<DataValue>, Unit: String
}
Field, FieldFacet, FieldReview {
Type: TypeRef, Minimum: u32, Maximum: Option<u32>, Unit: String,
Direction: ParameterDirection, Storage: FieldStorage, Default: Option<Vec<DataValue>>
}
Port, PortFacet, PortReview {
Constructor: PortConstructor, ComponentKind: ComponentPortKind,
Orientation: ComponentPortOrientation, LegacyDirection: PortDirection,
LegacyInterface: Option<Id>
}
Scenario, ScenarioFacet, ScenarioReview {
Viewpoint: ScenarioViewpoint, Context: Option<Id>, Precondition: String, Postcondition: String
}
Lifeline, LifelineFacet, LifelineReview { Represented: Option<Id> }
Occurrence, OccurrenceFacet, OccurrenceReview {
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
Operation: Option<OperationUse>,
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
}
Event, EventFacet, EventReview {
Constructor: EventConstructor, TimeKind: TimeEventKind, TimeExpression: String, ChangeExpression: String
}
State, StateFacet, StateReview { Kind: StateKind, Entry: String, Exit: String, DoActivity: String }
Constraint, ConstraintFacet, ConstraintReview { Expression: String }
Relation, RelationFacet, RelationReview {
Source: Id, Target: Id, AllocationOrder: u32, Transmission: TransmissionProtocol,
Acquisition: AcquisitionProtocol, SourceRole: String, TargetRole: String,
SourceMinimum: u32, SourceMaximum: Option<u32>, TargetMinimum: u32,
TargetMaximum: Option<u32>, SourceNavigable: bool, TargetNavigable: bool,
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
Guard: String, Effect: String, StateActionPhase: StateActionKind
, ContextOwner: Id, ContextSource: OccurrenceEndpoint, ContextTarget: OccurrenceEndpoint,
ContextPurpose: ContextualLinkPurpose
}
}};
}
pub(super) use scalar_families;
macro_rules! define_families {
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
$(
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum $selector { $( $leaf ),* }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum $review { $( $leaf(Reviewed<$ty>) ),* }
)*
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetSelector {
$( $family($selector), )*
Member { collection: ReferenceCollection, keys: KeyPair<Id> },
Order(OrderedCollection),
Literal(KeyPair<String>),
Annotation(KeyPair<AnnotationKey>),
Binding(KeyPair<Id>),
Child(KeyPair<Id>),
Incident { keys: KeyPair<Id>, end: IncidentEnd },
Artifact { keys: KeyPair<Id>, facet: ArtifactFacet },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetReview {
$( $family($review), )*
Member { collection: ReferenceCollection, keys: KeyPair<Id>, review: Reviewed<bool> },
Order { collection: OrderedCollection, review: Reviewed<Vec<Id>> },
Literal { keys: KeyPair<String>, review: Reviewed<bool> },
Annotation { keys: KeyPair<AnnotationKey>, review: Reviewed<Option<MessageValue>> },
Binding { keys: KeyPair<Id>, review: Reviewed<Option<LifelineBinding>> },
Child { keys: KeyPair<Id>, review: Reviewed<bool> },
Incident { keys: KeyPair<Id>, end: IncidentEnd, review: Reviewed<bool> },
Artifact { keys: KeyPair<Id>, review: ArtifactReview },
}
impl FacetReview {
pub fn selector(&self) -> FacetSelector {
match self {
$( Self::$family(value) => FacetSelector::$family(match value {
$( $review::$leaf(_) => $selector::$leaf, )*
}), )*
Self::Member { collection, keys, .. } => FacetSelector::Member { collection: *collection, keys: keys.clone() },
Self::Order { collection, .. } => FacetSelector::Order(*collection),
Self::Literal { keys, .. } => FacetSelector::Literal(keys.clone()),
Self::Annotation { keys, .. } => FacetSelector::Annotation(keys.clone()),
Self::Binding { keys, .. } => FacetSelector::Binding(keys.clone()),
Self::Child { keys, .. } => FacetSelector::Child(keys.clone()),
Self::Incident { keys, end, .. } => FacetSelector::Incident { keys: keys.clone(), end: *end },
Self::Artifact { keys, review } => FacetSelector::Artifact { keys: keys.clone(), facet: review.facet() },
}
}
}
};
}
scalar_families!(define_families);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ReferenceCollection {
Steps,
FragmentBranches,
FunctionInputItems,
FunctionOutputItems,
SignalItems,
ExchangeItems,
CategoryExchanges,
TransitionTriggers,
TransitionEffects,
Constrained,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum OrderedCollection {
Steps,
FragmentBranches,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum IncidentEnd {
Source,
Target,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ArtifactFacet {
Presence,
Name,
Description,
Owner,
Relation,
Uri,
Revision,
Fragment,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ArtifactReview {
Presence(Reviewed<bool>),
Name(Reviewed<String>),
Description(Reviewed<String>),
Owner(Reviewed<Id>),
Relation(Reviewed<ArtifactRelation>),
Uri(Reviewed<String>),
Revision(Reviewed<Option<String>>),
Fragment(Reviewed<Option<String>>),
}
impl ArtifactReview {
pub fn facet(&self) -> ArtifactFacet {
match self {
Self::Presence(_) => ArtifactFacet::Presence,
Self::Name(_) => ArtifactFacet::Name,
Self::Description(_) => ArtifactFacet::Description,
Self::Owner(_) => ArtifactFacet::Owner,
Self::Relation(_) => ArtifactFacet::Relation,
Self::Uri(_) => ArtifactFacet::Uri,
Self::Revision(_) => ArtifactFacet::Revision,
Self::Fragment(_) => ArtifactFacet::Fragment,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticBaseline {
pub facets: Vec<FacetReview>,
}
+18
View File
@@ -114,6 +114,7 @@ pub struct PreparedCommand {
struct IdentityProvenance {
original: BTreeSet<Id>,
current_to_original: BTreeMap<Id, Id>,
explicit_replacement: bool,
}
impl IdentityProvenance {
@@ -123,10 +124,12 @@ impl IdentityProvenance {
Self {
original,
current_to_original,
explicit_replacement: false,
}
}
fn replace(&mut self, project: &Project) {
self.explicit_replacement = true;
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
self.current_to_original = self
.original
@@ -180,6 +183,13 @@ pub fn prepare_command(
)?;
crate::references::finish_deletions(&mut candidate, &pending_deletions);
provenance.retain_present(&candidate);
if !provenance.explicit_replacement {
crate::data::prune_removed_inheritance(
project,
&mut candidate,
&provenance.current_to_original,
);
}
validate_candidate(&candidate)?;
Ok(PreparedCommand {
project: candidate,
@@ -287,6 +297,13 @@ pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project,
)?;
}
crate::references::finish_deletions(&mut candidate, &pending_deletions);
if !provenance.explicit_replacement {
crate::data::prune_removed_inheritance(
project,
&mut candidate,
&provenance.current_to_original,
);
}
validate_candidate(&candidate)?;
Ok(candidate)
}
@@ -643,6 +660,7 @@ fn apply_command(
.unwrap();
*occ.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(*exchange),
sender: None,
receiver: None,
+707 -6
View File
@@ -9,6 +9,13 @@
//! may remain after interruption during staging/completed cleanup. They contain
//! no pending transaction unless named `.arcadia-save-ENTRY.journal`. Windows
//! has no portable directory fsync; this is not a power-loss durability promise.
//! Journal v2 records whether writes may have begun with a flushed marker.
//! Recovery of a v2 journal without that marker discards only journal metadata;
//! v1 journals retain their prior rollback rules.
//! Journal publication refuses replacement on Windows and Linux, including a
//! competing empty directory. Linux requires kernel/filesystem support for
//! `renameat2(RENAME_NOREPLACE)` and fails without a fallback when unavailable.
//! Other operating systems retain standard rename semantics.
use crate::{language, source_project::*};
use serde::{Deserialize, Serialize};
use std::{
@@ -22,6 +29,8 @@ use thiserror::Error;
const MAX_PATH_BYTES: usize = 4096;
const MAX_JOURNAL_BYTES: usize = 32 * 1024 * 1024;
const MAX_JOURNAL_FILES: usize = 4096;
const WRITE_STARTED: &str = "write-started";
const WRITE_STARTED_BYTES: &[u8] = b"Arcadia journal v2: model writes may have started\n";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiskExpectation {
@@ -588,6 +597,127 @@ fn staged_path(journal: &Path, index: usize, suffix: &str) -> PathBuf {
journal.join(format!("{index}.{suffix}"))
}
fn publish_journal(stage: &Path, journal: &Path) -> Result<(), SourceFileError> {
#[cfg(windows)]
{
publish_journal_with_retry(
stage,
journal,
rename_journal_without_replacement,
std::thread::sleep,
)
}
#[cfg(target_os = "linux")]
{
rename_journal_without_replacement(stage, journal).map_err(|error| io_at(journal, error))
}
// Other platforms retain their existing rename behavior; the native
// no-replacement publication guarantee is currently Windows/Linux only.
#[cfg(not(any(windows, target_os = "linux")))]
{
fs::rename(stage, journal).map_err(|error| io_at(journal, error))
}
}
#[cfg(target_os = "linux")]
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
use std::{ffi::CString, os::unix::ffi::OsStrExt};
let path_bytes = |path: &Path| {
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"Journal path contains an interior NUL.",
)
})
};
let (stage, journal) = (path_bytes(stage)?, path_bytes(journal)?);
// Use libc's architecture-specific syscall number, avoiding a dependency on
// the newer glibc renameat2 wrapper. Both NUL-terminated byte buffers remain
// alive for the call. RENAME_NOREPLACE makes even an empty competing directory
// an error. Unsupported kernels/filesystems fail closed: never fall back to
// ordinary rename, whose replacement semantics would lose journal ownership.
let result = unsafe {
libc::syscall(
libc::SYS_renameat2,
libc::AT_FDCWD as libc::c_long,
stage.as_ptr(),
libc::AT_FDCWD as libc::c_long,
journal.as_ptr(),
libc::RENAME_NOREPLACE as libc::c_ulong,
)
};
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(windows)]
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
#[link(name = "kernel32")]
unsafe extern "system" {
fn MoveFileExW(existing: *const u16, destination: *const u16, flags: u32) -> i32;
}
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<_> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Journal path contains an interior NUL.",
));
}
value.push(0);
Ok(value)
};
let (stage, journal) = (wide(stage)?, wide(journal)?);
// Both terminated UTF-16 buffers live through the call. Flags=0 requests
// neither replacement, cross-volume copying nor deferred execution. Unlike
// fs::rename on modern Windows, an existing empty directory is not replaced.
if unsafe { MoveFileExW(stage.as_ptr(), journal.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Windows scanners may briefly open newly written stage files without delete
// sharing. Retry only publication, before any model file has been replaced.
// Ownership still transfers only after one successful rename. Never overwrite
// a journal that appeared while waiting, and retain the final OS error/path.
#[cfg(any(windows, test))]
fn publish_journal_with_retry(
stage: &Path,
journal: &Path,
mut rename: impl FnMut(&Path, &Path) -> io::Result<()>,
mut pause: impl FnMut(std::time::Duration),
) -> Result<(), SourceFileError> {
let mut delays = [5, 10, 25, 50, 100, 200].into_iter();
loop {
match fs::symlink_metadata(journal) {
Ok(_) => return Err(SourceFileError::RecoveryRequired(journal.into())),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(io_at(journal, error)),
}
match rename(stage, journal) {
Ok(()) => return Ok(()),
Err(error) => {
// ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION.
// A permanent ACL failure is still returned after at most 390 ms.
if matches!(error.raw_os_error(), Some(5 | 32 | 33))
&& let Some(delay) = delays.next()
{
pause(std::time::Duration::from_millis(delay));
continue;
}
return Err(io_at(journal, error));
}
}
}
}
fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), SourceFileError> {
let mut file = File::options()
.write(true)
@@ -699,6 +829,26 @@ fn save_with_hook(
compiled: &CompiledSourceProject,
limits: ProjectLimits,
mut after_replace: impl FnMut(usize) -> io::Result<()>,
) -> Result<DiskProject, SourceFileError> {
save_with_phases(snapshot, compiled, limits, |phase| match phase {
SavePhase::AfterReplace(count) => after_replace(count),
_ => Ok(()),
})
}
enum SavePhase {
BeforePublish,
AfterPublish,
AfterWriteMarker,
AfterReplace(usize),
BeforeRetire,
}
fn save_with_phases(
snapshot: &DiskProject,
compiled: &CompiledSourceProject,
limits: ProjectLimits,
mut phase: impl FnMut(SavePhase) -> io::Result<()>,
) -> Result<DiskProject, SourceFileError> {
check_snapshot(snapshot, limits)?;
let checked = compile_bundle(&compiled.bundle, limits).map_err(|diagnostics| {
@@ -739,7 +889,7 @@ fn save_with_hook(
// Stable dependency order, with the project entry committed last.
entries.sort_by_key(|file| (file.path == snapshot.bundle.entry, file.path.clone()));
let journal = Journal {
version: 1,
version: 2,
entry: snapshot.bundle.entry.clone(),
files: entries,
};
@@ -775,20 +925,46 @@ fn save_with_hook(
}
write_synced(&stage.path().join("journal.json"), &metadata)?;
sync_directory(stage.path())?;
phase(SavePhase::BeforePublish).map_err(|error| io_at(&journal_dir, error))?;
// Publishing a complete directory makes interrupted staging harmless.
fs::rename(stage.path(), &journal_dir).map_err(|error| io_at(&journal_dir, error))?;
publish_journal(stage.path(), &journal_dir)?;
let _ = stage.keep(); // The renamed directory now belongs to recovery.
sync_directory(&snapshot.root)?;
phase(SavePhase::AfterPublish).map_err(|error| io_at(&journal_dir, error))?;
// Publication may have waited while another writer completed the same
// candidate. Nothing has been written by this save: never interpret their
// matching after-bytes as ours and roll them back. The absent v2 marker
// also preserves this distinction after interruption or retirement failure.
if let Err(error) = check_conflicts(&snapshot.root, &snapshot.expectations) {
let retirement = phase(SavePhase::BeforeRetire)
.map_err(|error| io_at(&journal_dir, error))
.and_then(|()| retire_journal(&snapshot.root, &journal_dir, &journal));
return match retirement {
Ok(()) => Err(error),
Err(retirement) => Err(SourceFileError::RecoveryFailed {
save: error.to_string(),
recovery: retirement.to_string(),
journal: journal_dir,
}),
};
}
write_synced(&journal_dir.join(WRITE_STARTED), WRITE_STARTED_BYTES)?;
sync_directory(&journal_dir)?;
phase(SavePhase::AfterWriteMarker).map_err(|error| io_at(&journal_dir, error))?;
let result = (|| {
let mut current = snapshot.expectations.clone();
let mut replacements = 0;
for file in journal.files.iter().filter(|file| file.write) {
check_conflicts(&snapshot.root, &current)?;
// The first check is above, before the durable write-attempt marker.
// Further checks follow real write attempts and may need rollback.
if replacements != 0 {
check_conflicts(&snapshot.root, &current)?;
}
let bytes = compiled.bundle.files[&file.path].as_bytes();
atomic_write(&snapshot.root, &file.path, bytes)?;
current.insert(file.path.clone(), DiskExpectation::Bytes(bytes.to_vec()));
replacements += 1;
after_replace(replacements)
phase(SavePhase::AfterReplace(replacements))
.map_err(|error| io_at(&snapshot.root.join(&file.path), error))?;
}
check_conflicts(&snapshot.root, &current)?;
@@ -804,6 +980,7 @@ fn save_with_hook(
}),
};
}
phase(SavePhase::BeforeRetire).map_err(|error| io_at(&journal_dir, error))?;
retire_journal(&snapshot.root, &journal_dir, &journal)?;
Ok(saved_snapshot(snapshot, compiled))
}
@@ -833,6 +1010,14 @@ fn remove_journal(directory: &Path, journal: &Journal) -> Result<(), SourceFileE
}
}
}
if journal.version == 2 {
let marker = directory.join(WRITE_STARTED);
match fs::remove_file(&marker) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(io_at(&marker, error)),
}
}
let metadata = directory.join("journal.json");
fs::remove_file(&metadata).map_err(|error| io_at(&metadata, error))?;
fs::remove_dir(directory).map_err(|error| io_at(directory, error))
@@ -898,7 +1083,10 @@ fn recover_with_hook(
let bytes = read_journal_file(&metadata_path, MAX_JOURNAL_BYTES)?;
let journal: Journal = serde_json::from_slice(&bytes)
.map_err(|error| SourceFileError::Invalid(format!("Invalid recovery journal: {error}")))?;
if journal.version != 1 || journal.entry != entry || journal.files.len() > limits.max_files {
if !matches!(journal.version, 1 | 2)
|| journal.entry != entry
|| journal.files.len() > limits.max_files
{
return Err(SourceFileError::Invalid(
"Recovery journal version, entry or file budget is invalid.".into(),
));
@@ -908,6 +1096,25 @@ fn recover_with_hook(
let mut folded = BTreeSet::new();
let (mut before_bytes, mut after_bytes) = (0usize, 0usize);
let mut allowed = BTreeSet::from(["journal.json".to_string()]);
let write_started = if journal.version == 2 {
let marker = directory.join(WRITE_STARTED);
match fs::symlink_metadata(&marker) {
Ok(_) => {
if read_journal_file(&marker, WRITE_STARTED_BYTES.len())? != WRITE_STARTED_BYTES {
return Err(SourceFileError::Invalid(
"Invalid journal write-started marker.".into(),
));
}
allowed.insert(WRITE_STARTED.into());
true
}
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) => return Err(io_at(&marker, error)),
}
} else {
// Frozen v1 journals predate the marker and retain their rollback rules.
true
};
for (index, file) in journal.files.iter().enumerate() {
disk_path(&root, &file.path)?;
if !folded.insert(file.path.to_lowercase()) {
@@ -952,6 +1159,10 @@ fn recover_with_hook(
));
}
}
if !write_started {
retire_journal(&root, &directory, &journal)?;
return Ok(true);
}
let mut current = BTreeMap::new();
let mut conflicts = Vec::new();
for (path, before) in &original {
@@ -995,11 +1206,347 @@ fn recover_with_hook(
mod transaction_tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_preserves_competing_destinations_and_our_stage() {
use std::os::unix::{fs::MetadataExt, fs::symlink};
for kind in ["empty directory", "nonempty directory", "file", "symlink"] {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our complete metadata").unwrap();
// Simulate a competitor arriving after the caller's absence check.
assert!(!journal.exists());
match kind {
"empty directory" => fs::create_dir(&journal).unwrap(),
"nonempty directory" => {
fs::create_dir(&journal).unwrap();
fs::write(journal.join("journal.json"), b"competitor metadata").unwrap();
}
"file" => fs::write(&journal, b"competitor file").unwrap(),
"symlink" => symlink("absent competitor target", &journal).unwrap(),
_ => unreachable!(),
}
let before = fs::symlink_metadata(&journal).unwrap();
let error = publish_journal(&stage, &journal).unwrap_err();
assert!(
matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(libc::EEXIST)),
"{kind} must refuse publication"
);
let after = fs::symlink_metadata(&journal).unwrap();
assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino()));
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our complete metadata"
);
match kind {
"empty directory" => assert_eq!(fs::read_dir(&journal).unwrap().count(), 0),
"nonempty directory" => assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"competitor metadata"
),
"file" => assert_eq!(fs::read(&journal).unwrap(), b"competitor file"),
"symlink" => assert_eq!(
fs::read_link(&journal).unwrap(),
Path::new("absent competitor target")
),
_ => unreachable!(),
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_preserves_raw_path_bytes_and_rejects_nul() {
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join(OsStr::from_bytes(b"stage-\xff"));
let journal = directory.path().join(OsStr::from_bytes(b"journal-\xfe"));
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"complete metadata").unwrap();
for (a, b) in [
(Path::new("bad\0path"), journal.as_path()),
(stage.as_path(), Path::new("bad\0path")),
] {
let error = rename_journal_without_replacement(a, b).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(stage.join("journal.json").exists());
assert!(!journal.exists());
}
publish_journal(&stage, &journal).unwrap();
assert!(!stage.exists());
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"complete metadata"
);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_has_one_winner_for_concurrent_stages() {
use std::sync::Barrier;
let directory = tempfile::tempdir().unwrap();
let journal = directory.path().join("journal");
let stages = [
directory.path().join("stage-a"),
directory.path().join("stage-b"),
];
for (i, stage) in stages.iter().enumerate() {
fs::create_dir(stage).unwrap();
fs::write(stage.join("journal.json"), format!("complete writer {i}")).unwrap();
}
let barrier = Barrier::new(2);
let results = std::thread::scope(|scope| {
let journal = &journal;
let barrier = &barrier;
let handles: Vec<_> = stages
.iter()
.map(|stage| {
scope.spawn(move || {
assert!(!journal.exists());
barrier.wait();
publish_journal(stage, journal)
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
for (i, result) in results.into_iter().enumerate() {
match result {
Ok(()) => {
assert!(!stages[i].exists());
assert_eq!(
fs::read_to_string(journal.join("journal.json")).unwrap(),
format!("complete writer {i}")
);
}
Err(SourceFileError::Io { path, source }) => {
assert_eq!(path, journal);
assert_eq!(source.raw_os_error(), Some(libc::EEXIST));
assert_eq!(
fs::read_to_string(stages[i].join("journal.json")).unwrap(),
format!("complete writer {i}")
);
}
Err(error) => panic!("unexpected publication error: {error}"),
}
}
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires strace renameat2 error injection and OA_TEST_RENAMEAT2_ERRNO"]
fn linux_journal_publication_injected_unsupported_error_is_fail_closed() {
let errno: i32 = std::env::var("OA_TEST_RENAMEAT2_ERRNO")
.expect("set OA_TEST_RENAMEAT2_ERRNO to the injected Linux errno")
.parse()
.unwrap();
assert!(matches!(
errno,
libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP
));
let (directory, snapshot, compiled) = fixture();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
let original: Vec<_> = snapshot
.bundle
.files
.keys()
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
.collect();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(
phase,
SavePhase::AfterPublish | SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_)
) {
panic!("unsupported no-replace publication must stop before ownership transfer");
}
Ok(())
})
.unwrap_err();
assert!(matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(errno)));
assert!(!journal.exists());
for (path, bytes) in original {
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
}
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_refusal_never_starts_model_writes() {
let (directory, snapshot, compiled) = fixture();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
let original: Vec<_> = snapshot
.bundle
.files
.keys()
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
.collect();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
match phase {
SavePhase::BeforePublish => fs::create_dir(&journal).unwrap(),
SavePhase::AfterPublish
| SavePhase::AfterWriteMarker
| SavePhase::AfterReplace(_) => {
panic!("a rejected publisher must not obtain ownership or write source files")
}
_ => {}
}
Ok(())
})
.unwrap_err();
assert!(matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(libc::EEXIST)));
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
assert!(!journal.join(WRITE_STARTED).exists());
for (path, bytes) in original {
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
}
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
#[cfg(windows)]
#[test]
fn journal_publication_does_not_replace_empty_target_created_after_precheck() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our journal").unwrap();
let mut called = false;
let result = publish_journal_with_retry(
&stage,
&journal,
|a, b| {
assert!(!called);
called = true;
fs::create_dir(b).unwrap();
rename_journal_without_replacement(a, b)
},
|_| {},
);
assert!(result.is_err());
assert!(called);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our journal"
);
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
assert!(
matches!(rename_journal_without_replacement(Path::new("bad\0path"), &journal), Err(error) if error.kind() == io::ErrorKind::InvalidInput)
);
}
#[test]
fn journal_publication_retries_only_transient_windows_errors_before_transferring_ownership() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"complete staged metadata").unwrap();
let mut errors = [5, 32, 33].into_iter();
let mut waits = vec![];
publish_journal_with_retry(
&stage,
&journal,
|a, b| {
assert!(a.join("journal.json").exists());
assert!(!b.exists());
match errors.next() {
Some(code) => Err(io::Error::from_raw_os_error(code)),
None => fs::rename(a, b),
}
},
|delay| waits.push(delay.as_millis()),
)
.unwrap();
assert_eq!(waits, [5, 10, 25]);
assert!(!stage.exists());
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"complete staged metadata"
);
}
#[test]
fn journal_publication_exhaustion_and_permanent_errors_keep_stage_and_report_original_path() {
for (code, expected_calls, expected_wait) in [(5, 7, 390), (123, 1, 0)] {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"before publication").unwrap();
let (mut calls, mut waited) = (0, 0);
let error = publish_journal_with_retry(
&stage,
&journal,
|_, _| {
calls += 1;
Err(io::Error::from_raw_os_error(code))
},
|delay| waited += delay.as_millis(),
)
.unwrap_err();
assert_eq!((calls, waited), (expected_calls, expected_wait));
assert!(
matches!(error, SourceFileError::Io { path, source } if path == journal && source.raw_os_error() == Some(code))
);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"before publication"
);
assert!(!journal.exists());
}
}
#[test]
fn journal_publication_never_replaces_another_journal_that_appears_during_retry() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our staged journal").unwrap();
let mut calls = 0;
let error = publish_journal_with_retry(
&stage,
&journal,
|_, _| {
calls += 1;
Err(io::Error::from_raw_os_error(32))
},
|_| {
fs::create_dir(&journal).unwrap();
fs::write(journal.join("journal.json"), b"other save owns this").unwrap();
},
)
.unwrap_err();
assert_eq!(calls, 1);
assert!(matches!(error, SourceFileError::RecoveryRequired(path) if path == journal));
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"other save owns this"
);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our staged journal"
);
}
fn fixture() -> (tempfile::TempDir, DiskProject, CompiledSourceProject) {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
)
.unwrap();
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
@@ -1014,6 +1561,160 @@ mod transaction_tests {
(directory, snapshot, compiled)
}
#[test]
fn unwritten_journal_never_rolls_back_a_completed_writer_with_identical_after_bytes() {
for interrupt_retirement in [false, true] {
let (directory, snapshot, compiled) = fixture();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
match phase {
SavePhase::BeforePublish => {
// A competing save finishes during our publication wait.
save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
}
SavePhase::BeforeRetire if interrupt_retirement => {
return Err(io::Error::other("injected no-write retirement failure"));
}
SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_) => {
panic!("losing writer must not start writes")
}
_ => {}
}
Ok(())
})
.unwrap_err();
assert!(matches!(
error,
SourceFileError::Conflict(_) | SourceFileError::RecoveryFailed { .. }
));
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert_eq!(journal.exists(), interrupt_retirement);
if interrupt_retirement {
assert!(!journal.join(WRITE_STARTED).exists());
// Explicit recovery after a failed retirement must also leave
// the completed other writer untouched, even matching after bytes.
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
}
assert!(!journal.exists());
}
}
#[test]
fn recovery_before_any_write_accepts_v2_marker_and_keeps_v1_rollback_compatibility() {
for version in [1, 2] {
let (directory, snapshot, compiled) = fixture();
let interrupted = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterWriteMarker) {
panic!("interrupted before atomic write");
}
Ok(())
});
});
assert!(interrupted.is_err());
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert_eq!(
fs::read(journal.join(WRITE_STARTED)).unwrap(),
WRITE_STARTED_BYTES
);
if version == 1 {
// A frozen v1 journal has no marker and means writes may have begun.
let path = journal.join("journal.json");
let mut metadata: Journal =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
metadata.version = 1;
fs::write(path, serde_json::to_vec(&metadata).unwrap()).unwrap();
fs::remove_file(journal.join(WRITE_STARTED)).unwrap();
for (path, bytes) in &compiled.bundle.files {
fs::write(directory.path().join(path), bytes).unwrap();
}
}
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
}
#[test]
fn interrupted_publication_without_write_marker_never_rolls_back_external_matching_bytes() {
let (directory, snapshot, compiled) = fixture();
let interrupted = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterPublish) {
panic!("interrupted before write marker");
}
Ok(())
});
});
assert!(interrupted.is_err());
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert!(!journal.join(WRITE_STARTED).exists());
for (path, bytes) in &compiled.bundle.files {
fs::write(directory.path().join(path), bytes).unwrap();
}
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
assert!(!journal.exists());
}
#[test]
fn malformed_v2_write_marker_refuses_recovery_without_changing_source_bytes() {
let (directory, snapshot, compiled) = fixture();
let _ = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterWriteMarker) {
panic!("interrupted");
}
Ok(())
});
});
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
fs::write(journal.join(WRITE_STARTED), b"partial").unwrap();
assert!(matches!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
),
Err(SourceFileError::Invalid(_))
));
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
assert!(journal.exists());
}
#[test]
fn partial_save_failures_restore_every_original_and_write_entry_last() {
for stop in 1..=3 {
+102 -22
View File
@@ -1,9 +1,11 @@
//! Pure, bounded source3 projects. All includes resolve against supplied UTF-8
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
//! documents; this module never reads a filesystem, URL, or process directory.
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation};
use language::{Diagnostic, ParseLimits, Span};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[path = "source_project/source_migration.rs"]
mod source_migration;
#[path = "source_project/source_structure.rs"]
mod source_structure;
@@ -278,21 +280,24 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
}
}
/// Compile all reachable files as one source3 model and stabilize omitted UUIDs.
/// Compile reachable source3/4/5 files, explicitly upgrade contracts, and stabilize UUIDs.
/// Missing/unreachable files and every admission or engineering error leave the
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
pub fn compile_bundle(
bundle: &SourceBundle,
limits: ProjectLimits,
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
let (compiled, insertions) = compile_once(bundle, limits)?;
if insertions.is_empty() {
return Ok(compiled);
let (mut compiled, insertions) = compile_once(bundle, limits)?;
if !insertions.is_empty() {
let mut stable = bundle.clone();
apply_edits(&mut stable, insertions)?;
// Re-index actual returned bytes before performing the schema upgrade.
compiled = compile_once(&stable, limits)?.0;
}
let mut stable = bundle.clone();
apply_edits(&mut stable, insertions)?;
// Re-index the actual returned bytes. Never expose offsets into pre-ID text.
compile_once(&stable, limits).map(|(compiled, _)| compiled)
if compiled.decoded.migration.is_some() {
return source_migration::upgrade(&compiled, limits);
}
Ok(compiled)
}
fn compile_once(
@@ -568,7 +573,7 @@ fn compile_once(
span,
}),
};
let project =
let decoded =
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
@@ -578,7 +583,7 @@ fn compile_once(
related: Vec::new(),
}]
})?;
let diagnostics = model_diagnostics(bundle, &index, &project);
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
let errors: Vec<_> = diagnostics
.iter()
.filter(|issue| issue.diagnostic.severity == validation::Severity::Error)
@@ -590,10 +595,7 @@ fn compile_once(
Ok((
CompiledSourceProject {
bundle: bundle.clone(),
decoded: DecodeResult {
project,
migration: None,
},
decoded,
index,
diagnostics,
},
@@ -718,7 +720,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
Ok(())
}
/// Canonical, readable source3 export. Like `language::format`, the caller is
/// Canonical, readable current-version export. Like `language::format`, the caller is
/// responsible for supplying a valid model; compilation checks the result.
/// Existing source layout is preserved by `update_bundle`, not this formatter.
pub fn format_bundle(project: &Project) -> SourceBundle {
@@ -734,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.push_str(&unindent(&declaration));
}
let mut entry = format!(
"arcadia 3;\n{}{{\n description {};\n",
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
@@ -923,6 +925,8 @@ pub fn update_bundle_with_identity_map(
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
identity_origins: Some(&origins),
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
@@ -951,6 +955,8 @@ pub fn update_bundle_with_identity_map(
after: &after,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
identity_origins: Some(&origins),
};
let mut local = Vec::new();
update.header(
@@ -1119,6 +1125,10 @@ struct SyntaxUpdate<'a> {
after: &'a str,
old_aliases: &'a BTreeMap<String, String>,
new_aliases: &'a BTreeMap<String, String>,
// Source3 migration only adds path qualification; the existing field identity
// must keep its authored argument trivia during that one-time transformation.
legacy_value_paths: bool,
identity_origins: Option<&'a BTreeMap<Id, Id>>,
}
impl SyntaxUpdate<'_> {
fn header(
@@ -1214,6 +1224,18 @@ impl SyntaxUpdate<'_> {
alias.clone(),
));
}
(EditKind::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
.pop()
.expect("historical UUID string");
edits.push((
Span {
start: actual.span.start + token.span.start,
end: actual.span.start + token.span.end,
},
quote(&id.to_string()),
));
}
(EditKind::Atom, EditKind::Atom) => edits.push((
actual.span,
self.after[after.span.start..after.span.end].into(),
@@ -1338,11 +1360,27 @@ impl SyntaxUpdate<'_> {
let previous = before.unwrap_or(&[]);
let old_keys: Option<Vec<_>> = previous
.iter()
.map(|item| item_key(item, self.before, self.old_aliases))
.map(|item| {
item_key(
item,
self.before,
self.old_aliases,
self.legacy_value_paths,
None,
)
})
.collect();
let new_keys: Option<Vec<_>> = after
.iter()
.map(|item| item_key(item, self.after, self.new_aliases))
.map(|item| {
item_key(
item,
self.after,
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
)
})
.collect();
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
old.iter().collect::<BTreeSet<_>>().len() == old.len()
@@ -1449,9 +1487,23 @@ fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
) -> Option<String> {
use language::EditKind;
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
origins.unwrap_or(&BTreeMap::new()),
)
{
return Some(key);
}
match &value.kind {
EditKind::HistoricalUuid(id) => Some(format!(
"uuid:{}",
origins.and_then(|map| map.get(id)).unwrap_or(id)
)),
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
EditKind::Atom => Some(format!(
"literal:{}",
@@ -1462,7 +1514,31 @@ fn item_key(
.find_map(|key| {
fields.get(key).and_then(|field| {
if let EditKind::Reference(alias) = &field.value.kind {
aliases.get(alias).map(|id| format!("{key}:{id}"))
aliases.get(alias).map(|id| {
if key == "field" && !legacy_value_paths {
let path = fields
.get("path")
.and_then(|field| {
if let EditKind::List(path) = &field.value.kind {
path.iter()
.map(|hop| {
if let EditKind::Reference(alias) = &hop.kind {
aliases.get(alias).cloned()
} else {
None
}
})
.collect::<Option<Vec<_>>>()
} else {
None
}
})
.unwrap_or_default();
format!("field:{}:{id}", path.join("/"))
} else {
format!("{key}:{id}")
}
})
} else {
None
}
@@ -1477,7 +1553,11 @@ fn semantic_owner(project: &Project, id: Id) -> Option<Id> {
return element.parent;
}
if let Some(relation) = project.relations.get(&id) {
return Some(relation.source());
return Some(
relation
.contextual_owner()
.unwrap_or_else(|| relation.source()),
);
}
if let Some(artifact) = project.artifact_links.get(&id) {
return Some(artifact.owner);
@@ -0,0 +1,127 @@
//! Source3/4 upgrade through token edits; authored modules, nesting and trivia survive.
use super::*;
pub(super) fn upgrade(
current: &CompiledSourceProject,
limits: ProjectLimits,
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
let report = current
.decoded
.migration
.as_ref()
.expect("an explicit source migration report");
let project = &current.decoded.project;
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
.filter(|default| default.field != "baseline.semantic")
.map(|default| default.object)
.collect();
let aliases: BTreeMap<_, _> = project
.identities()
.into_iter()
.map(|(id, alias)| (alias.to_owned(), id.to_string()))
.collect();
let mut edits: Edits = BTreeMap::new();
for (path, source) in &current.bundle.files {
let declarations =
language::edit_declarations(source, path == &current.bundle.entry, limits.model)
.map_err(|diagnostic| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *diagnostic,
related: vec![],
}]
})?;
for (id, actual) in declarations {
if !changed.contains(&id) {
continue;
}
let Some(after) = canonical.get(&id) else {
continue;
};
let mut next = language::edit_declaration(after).map_err(|diagnostic| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *diagnostic,
related: vec![],
}]
})?;
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
&& let language::EditKind::Record(fields) = &mut next.fields.kind
{
fields.remove("parent");
}
let update = SyntaxUpdate {
source,
before: source,
after,
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
identity_origins: None,
};
update.value(
&actual.fields,
Some(&actual.fields),
&next.fields,
edits.entry(path.clone()).or_default(),
);
}
}
let source = &current.bundle.files[&current.bundle.entry];
let version = significant(source)
.get(1)
.map(|token| token.span)
.expect("a parsed version token");
edits
.entry(current.bundle.entry.clone())
.or_default()
.push((version, crate::SCHEMA_VERSION.to_string()));
// New allocation relations are emitted into the allocating interface's
// existing module, outside its lexical element body. Existing source never moves.
let mut added: BTreeMap<String, String> = BTreeMap::new();
for allocation in &report.generated_interface_allocations {
let path = current
.index
.declarations
.get(&allocation.source)
.map(|location| location.path.clone())
.unwrap_or_else(|| current.bundle.entry.clone());
let ending = newline(&current.bundle.files[&path]);
let text = added.entry(path).or_default();
text.push_str(ending);
text.push_str(&canonical[&allocation.id].replace('\n', ending));
}
for (path, text) in added {
let offset = if path == current.bundle.entry {
current.index.project_body.span.end
} else {
current.bundle.files[&path].len()
};
edits.entry(path).or_default().push((
Span {
start: offset,
end: offset,
},
text,
));
}
let mut bundle = current.bundle.clone();
apply_edits(&mut bundle, edits)?;
let (mut result, insertions) = compile_once(&bundle, limits)?;
if !insertions.is_empty() || result.decoded.project != *project {
return Err(vec![problem(
&bundle,
&bundle.entry,
Span::default(),
"migration_source_mismatch",
"Targeted source upgrade did not reproduce the migrated project.",
)]);
}
result.decoded.migration = Some(report.clone());
Ok(result)
}
+352
View File
@@ -0,0 +1,352 @@
//! Caller-qualified process/chain addresses. Cache residency never affects admission.
use crate::*;
use std::{
cell::RefCell,
collections::{BTreeMap, BTreeSet},
};
mod analysis;
pub use analysis::*;
pub const MAX_REFERENCE_DEPTH: usize = 64;
pub const MAX_ANALYSIS_NODES: usize = 50_000;
pub const MAX_ANALYSIS_EDGES: usize = 100_000;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ContextAddress {
pub owner: Id,
pub endpoint: OccurrenceEndpoint,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedOccurrence {
pub address: ContextAddress,
pub defining_sequence: Id,
pub work: Option<Id>,
pub exchange: Option<Id>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct ContextError {
pub code: &'static str,
pub message: String,
}
fn failure(code: &'static str, message: &str) -> ContextError {
ContextError {
code,
message: message.into(),
}
}
struct Record {
parent: Option<Id>,
layer: Layer,
kind: Kind,
reference: Option<Id>,
work: Option<Id>,
exchange: Option<Id>,
contribution: bool,
}
pub struct TopologyIndex<'a> {
project: &'a Project,
records: BTreeMap<Id, Record>,
children: BTreeMap<Id, Vec<Id>>,
link_definitions: BTreeMap<Id, Vec<Id>>,
contextual_callers: BTreeSet<Id>,
cache: RefCell<BTreeMap<ContextAddress, ResolvedOccurrence>>,
cache_capacity: usize,
}
impl<'a> TopologyIndex<'a> {
pub fn new(project: &'a Project) -> Self {
Self::with_cache_capacity(project, 4096)
}
pub fn with_cache_capacity(project: &'a Project, cache_capacity: usize) -> Self {
let records: BTreeMap<_, _> = project
.elements
.values()
.map(|e| {
let (reference, work, exchange, contribution) = match e.shared() {
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::SequenceReference { sequence } => {
(*sequence, None, None, true)
}
OccurrenceBody::Action { action, .. } => (None, *action, None, true),
OccurrenceBody::Exchange { exchange, .. } => (None, None, *exchange, true),
OccurrenceBody::Control { .. } => (None, None, None, true),
_ => (None, None, None, false),
},
_ => (None, None, None, false),
};
(
e.id,
Record {
parent: e.parent,
layer: e.layer(),
kind: e.kind(),
reference,
work,
exchange,
contribution,
},
)
})
.collect();
let mut children: BTreeMap<_, _> = project
.elements
.values()
.filter_map(|e| e.steps().map(|steps| (e.id, steps.clone())))
.collect();
let mut child_sets: BTreeMap<_, BTreeSet<_>> = children
.iter()
.map(|(owner, children)| (*owner, children.iter().copied().collect()))
.collect();
for (id, record) in &records {
if record.contribution
&& let Some(parent) = record.parent
{
let list = children.entry(parent).or_default();
if child_sets.entry(parent).or_default().insert(*id) {
list.push(*id);
}
}
}
for (owner, list) in &mut children {
list.retain(|id| {
records
.get(id)
.is_some_and(|r| r.parent == Some(*owner) && r.contribution)
});
}
let mut link_definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut contextual_callers = BTreeSet::new();
for relation in project.relations.values() {
let owner = relation.contextual_owner().or_else(|| {
if relation.kind() == RelationKind::SequenceLink {
records.get(&relation.source()).and_then(|r| r.parent)
} else {
None
}
});
if let Some(owner) = owner {
link_definitions.entry(owner).or_default().push(relation.id);
}
if let Some(owner) = relation.contextual_owner() {
contextual_callers.insert(owner);
}
}
let mut callers: BTreeMap<_, Vec<_>> = BTreeMap::new();
for record in records.values() {
if let (Some(parent), Some(reference)) = (record.parent, record.reference) {
callers.entry(reference).or_default().push(parent);
}
}
let mut pending: Vec<_> = contextual_callers.iter().copied().collect();
while let Some(sequence) = pending.pop() {
for caller in callers.get(&sequence).into_iter().flatten() {
if contextual_callers.insert(*caller) {
pending.push(*caller);
}
}
}
Self {
project,
records,
children,
link_definitions,
contextual_callers,
cache: RefCell::new(BTreeMap::new()),
cache_capacity,
}
}
/// Resolve a caller path while an endpoint leaf is still being edited.
pub fn resolve_scope(&self, owner: Id, references: &[Id]) -> Result<Id, ContextError> {
if references.len() > MAX_REFERENCE_DEPTH {
return Err(failure(
"context_depth",
"A contextual path exceeds 64 references.",
));
}
let root = self.records.get(&owner).ok_or_else(|| {
failure(
"context_owner",
"The caller must be an existing process or functional chain.",
)
})?;
if !matches!(root.kind, Kind::Process | Kind::FunctionalChain) {
return Err(failure(
"context_owner",
"The caller must be a process or functional chain.",
));
}
let mut current = owner;
let mut visited = BTreeSet::from([owner]);
for step in references {
let reference = self.records.get(step).ok_or_else(|| {
failure(
"context_path",
"A reference occurrence in the endpoint path no longer exists.",
)
})?;
if reference.parent != Some(current) || reference.layer != root.layer {
return Err(failure(
"context_path",
"Each reference occurrence must belong directly to the preceding caller context.",
));
}
let next = reference.reference.ok_or_else(|| {
failure(
"context_path",
"Each path component must be a resolved sequence-reference occurrence.",
)
})?;
let target = self.records.get(&next).ok_or_else(|| {
failure(
"context_path",
"A referenced process or chain no longer exists.",
)
})?;
if target.layer != root.layer || target.kind != root.kind {
return Err(failure(
"context_path",
"References must resolve to sequences of the same kind and layer.",
));
}
if !visited.insert(next) {
return Err(failure(
"context_cycle",
"A contextual endpoint cannot traverse recursive sequence reuse.",
));
}
current = next;
}
Ok(current)
}
pub fn resolve(
&self,
owner: Id,
endpoint: &OccurrenceEndpoint,
) -> Result<ResolvedOccurrence, ContextError> {
if endpoint.references.len() > MAX_REFERENCE_DEPTH {
return Err(failure(
"context_depth",
"A contextual endpoint exceeds the 64-reference depth limit.",
));
}
let address = ContextAddress {
owner,
endpoint: endpoint.clone(),
};
if let Some(cached) = self.cache.borrow().get(&address) {
return Ok(cached.clone());
}
let current = self.resolve_scope(owner, &endpoint.references)?;
let root = &self.records[&owner];
let leaf = self.records.get(&endpoint.occurrence).ok_or_else(|| {
failure(
"context_leaf",
"The endpoint contribution no longer exists.",
)
})?;
if leaf.parent != Some(current) || leaf.layer != root.layer || !leaf.contribution {
return Err(failure(
"context_leaf",
"The endpoint must be a direct contribution of its resolved process or chain.",
));
}
let resolved = ResolvedOccurrence {
address: address.clone(),
defining_sequence: current,
work: leaf.work,
exchange: leaf.exchange,
};
if self.cache_capacity > 0 {
let mut cache = self.cache.borrow_mut();
if cache.len() >= self.cache_capacity {
cache.pop_first();
}
cache.insert(address, resolved.clone());
}
Ok(resolved)
}
/// Validate a caller-owned semantic connection, independent of graphical expansion.
pub fn validate_link(
&self,
owner: Id,
source: &OccurrenceEndpoint,
target: &OccurrenceEndpoint,
purpose: &ContextualLinkPurpose,
) -> Result<(ResolvedOccurrence, ResolvedOccurrence), ContextError> {
let a = self.resolve(owner, source)?;
let b = self.resolve(owner, target)?;
if a.address == b.address {
return Err(failure(
"context_self",
"A contextual link needs two distinct occurrence addresses.",
));
}
match purpose {
ContextualLinkPurpose::Precedence { guard } => {
if source.references.is_empty() && target.references.is_empty() {
return Err(failure(
"context_direct_precedence",
"Use a SequenceLink for precedence between two direct contributions.",
));
}
if !validation::valid_text(guard) {
return Err(failure(
"invalid_text",
"The precedence guard contains invalid text.",
));
}
let previous = a.exchange.and_then(|id| self.project.relations.get(&id));
let next = b.exchange.and_then(|id| self.project.relations.get(&id));
let mismatch = match (a.work, previous, b.work, next) {
(Some(work), _, _, Some(exchange)) => work != exchange.source(),
(_, Some(exchange), Some(work), _) => exchange.target() != work,
(_, Some(previous), _, Some(next)) => previous.target() != next.source(),
_ => false,
};
if mismatch {
return Err(failure(
"sequence_exchange_endpoint",
"Adjacent work/exchange contributions must agree with the referenced exchange endpoints.",
));
}
}
ContextualLinkPurpose::Exchange { exchange } => {
let relation = self.project.relations.get(exchange).ok_or_else(|| {
failure("context_exchange", "Choose an existing work exchange.")
})?;
let expected = if self.project.layer_of(owner) == Some(Layer::Oa) {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
if relation.kind() != expected
|| self.project.layer_of(*exchange) != self.project.layer_of(owner)
|| a.work != Some(relation.source())
|| b.work != Some(relation.target())
{
return Err(failure(
"context_exchange",
"The selected same-layer exchange must connect the exact work referenced by the source and target Actions.",
));
}
}
ContextualLinkPurpose::CommonWork { work } => {
if !self.project.elements.get(work).is_some_and(|e| {
e.kind().is_work() && Some(e.layer()) == self.project.layer_of(owner)
}) || a.work != Some(*work)
|| b.work != Some(*work)
{
return Err(failure(
"context_common_work",
"Both Actions must reference the exact same existing work in this layer.",
));
}
}
}
Ok((a, b))
}
/// Direct child candidates for lazy UI exploration; never expands a Cartesian reuse tree.
pub fn contributions(&self, sequence: Id) -> Vec<Id> {
self.children.get(&sequence).cloned().unwrap_or_default()
}
}
@@ -0,0 +1,529 @@
use super::*;
#[cfg(test)]
#[path = "analysis_tests.rs"]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ReferenceBoundary {
Entry,
Exit,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ContextVertex {
pub address: ContextAddress,
pub boundary: Option<ReferenceBoundary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextEdgeKind {
Authored,
SyntheticBoundary,
LegacyOrder,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextEdge {
pub relation: Option<Id>,
pub instance: Vec<Id>,
pub source: ContextVertex,
pub target: ContextVertex,
pub guard: String,
pub kind: ContextEdgeKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextAssembly {
pub relation: Id,
pub instance: Vec<Id>,
pub source: ContextAddress,
pub target: ContextAddress,
pub purpose: ContextualLinkPurpose,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextNotice {
pub address: ContextAddress,
pub code: &'static str,
pub message: String,
pub relations: Vec<Id>,
}
#[derive(Debug, Clone, Copy)]
pub struct AnalysisLimits {
pub nodes: usize,
pub edges: usize,
}
impl Default for AnalysisLimits {
fn default() -> Self {
Self {
nodes: MAX_ANALYSIS_NODES,
edges: MAX_ANALYSIS_EDGES,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ContextAnalysis {
pub nodes: BTreeMap<ContextAddress, ResolvedOccurrence>,
pub precedence: Vec<ContextEdge>,
pub assembly: Vec<ContextAssembly>,
pub entries: Vec<ContextVertex>,
pub exits: Vec<ContextVertex>,
pub notices: Vec<ContextNotice>,
/// A partial expansion must never be used for complete reachability conclusions.
pub complete: bool,
}
struct Builder<'a, 'p> {
index: &'a TopologyIndex<'p>,
root: Id,
limits: AnalysisLimits,
result: ContextAnalysis,
definitions: BTreeMap<Id, Vec<&'p Relation>>,
examined_edges: usize,
exhausted: bool,
}
impl TopologyIndex<'_> {
pub fn analyze(&self, owner: Id, limits: AnalysisLimits) -> ContextAnalysis {
self.analyze_counted(owner, limits).0
}
fn analyze_counted(&self, owner: Id, limits: AnalysisLimits) -> (ContextAnalysis, usize) {
let mut definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
for (owner, relations) in &self.link_definitions {
definitions.insert(
*owner,
relations
.iter()
.filter_map(|id| self.project.relations.get(id))
.collect(),
);
}
let mut b = Builder {
index: self,
root: owner,
limits,
result: ContextAnalysis {
complete: true,
..ContextAnalysis::default()
},
definitions,
examined_edges: 0,
exhausted: false,
};
(b.result.entries, b.result.exits) = b.expand(owner, &[], &mut BTreeSet::new());
if b.result.complete {
b.cardinality();
b.connectivity();
}
(b.result, b.examined_edges)
}
pub fn has_contextual_links(&self, owner: Id) -> bool {
self.contextual_callers.contains(&owner)
}
}
impl Builder<'_, '_> {
fn address(&self, path: &[Id], occurrence: Id) -> ContextAddress {
ContextAddress {
owner: self.root,
endpoint: OccurrenceEndpoint {
references: path.to_vec(),
occurrence,
},
}
}
fn vertex(&self, address: ContextAddress, source: bool) -> ContextVertex {
let reference = self
.index
.project
.elements
.get(&address.endpoint.occurrence)
.and_then(Element::shared)
.is_some_and(|s| {
matches!(
s,
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
}
)
});
ContextVertex {
address,
boundary: reference.then_some(if source {
ReferenceBoundary::Exit
} else {
ReferenceBoundary::Entry
}),
}
}
fn incomplete(&mut self, address: ContextAddress, code: &'static str, message: &str) {
self.result.complete = false;
if !self
.result
.notices
.iter()
.any(|n| n.address == address && n.code == code)
{
self.result.notices.push(ContextNotice {
address,
code,
message: message.into(),
relations: vec![],
});
}
}
// Reserve work before resolving an authored endpoint, not merely before storing
// its output. Repeated malformed or filtered endpoints consume the same budget.
fn reserve_edge(&mut self, address: ContextAddress) -> bool {
if self.exhausted {
return false;
}
if self.examined_edges >= self.limits.edges {
self.exhausted = true;
self.incomplete(
address,
"context_analysis_limit",
"Contextual edge limit reached; connectivity analysis is incomplete.",
);
return false;
}
self.examined_edges += 1;
true
}
fn edge(&mut self, edge: ContextEdge) -> bool {
if !self.reserve_edge(edge.source.address.clone()) {
return false;
}
self.result.precedence.push(edge);
true
}
fn expand(
&mut self,
sequence: Id,
prefix: &[Id],
active: &mut BTreeSet<Id>,
) -> (Vec<ContextVertex>, Vec<ContextVertex>) {
if self.exhausted {
return (vec![], vec![]);
}
if prefix.len() > MAX_REFERENCE_DEPTH || !active.insert(sequence) {
self.incomplete(
self.address(prefix, sequence),
"context_analysis_incomplete",
"Recursive or over-depth reuse has no proven boundary path.",
);
return (vec![], vec![]);
}
let children = self.index.contributions(sequence);
if children.is_empty() {
self.incomplete(
self.address(prefix, sequence),
"context_analysis_incomplete",
"An empty or unresolved sequence has no proven boundary path.",
);
active.remove(&sequence);
return (vec![], vec![]);
}
let start = self.result.precedence.len();
for id in &children {
let address = self.address(prefix, *id);
if self.result.nodes.len() >= self.limits.nodes {
self.exhausted = true;
self.incomplete(
address,
"context_analysis_limit",
"Contextual node limit reached; connectivity analysis is incomplete.",
);
break;
}
match self.index.resolve(self.root, &address.endpoint) {
Ok(resolved) => {
self.result.nodes.insert(address.clone(), resolved);
}
Err(e) => {
self.incomplete(address, "context_analysis_incomplete", &e.message);
continue;
}
}
let reference = self.index.project.elements[id]
.shared()
.and_then(|s| match s {
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => Some(*sequence),
_ => None,
});
if let Some(reference) = reference {
let Some(callee) = reference else {
self.incomplete(
address,
"context_analysis_incomplete",
"An unresolved reference has no proven boundary path.",
);
continue;
};
let mut path = prefix.to_vec();
path.push(*id);
let (entries, exits) = self.expand(callee, &path, active);
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
if entries.is_empty() || exits.is_empty() {
self.incomplete(
address.clone(),
"context_analysis_incomplete",
"No referenced entry or exit is proven; no bypass is inferred.",
);
}
for target in entries {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source: self.vertex(address.clone(), false),
target,
guard: String::new(),
kind: ContextEdgeKind::SyntheticBoundary,
}) {
break;
}
}
for source in exits {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source,
target: self.vertex(address.clone(), true),
guard: String::new(),
kind: ContextEdgeKind::SyntheticBoundary,
}) {
break;
}
}
}
if self.exhausted {
break;
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
let relations = self.definitions.get(&sequence).cloned().unwrap_or_default();
for r in &relations {
if !self.reserve_edge(self.address(prefix, r.source())) {
break;
}
let (source, target, purpose) = match &r.body {
RelationBody::SequenceLink {
source,
target,
guard,
} => (
OccurrenceEndpoint {
references: vec![],
occurrence: *source,
},
OccurrenceEndpoint {
references: vec![],
occurrence: *target,
},
ContextualLinkPurpose::Precedence {
guard: guard.clone(),
},
),
RelationBody::ContextualLink {
source,
target,
purpose,
..
} => (source.clone(), target.clone(), purpose.clone()),
_ => continue,
};
let qualify = |mut endpoint: OccurrenceEndpoint| {
let mut path = prefix.to_vec();
path.extend(endpoint.references);
endpoint.references = path;
ContextAddress {
owner: self.root,
endpoint,
}
};
let source = qualify(source);
let target = qualify(target);
if !self.result.nodes.contains_key(&source) || !self.result.nodes.contains_key(&target)
{
self.incomplete(
source,
"context_analysis_incomplete",
"A link endpoint is unresolved in this bounded analysis.",
);
continue;
}
match purpose {
ContextualLinkPurpose::Precedence { guard } => {
self.result.precedence.push(ContextEdge {
relation: Some(r.id),
instance: prefix.to_vec(),
source: self.vertex(source, true),
target: self.vertex(target, false),
guard,
kind: ContextEdgeKind::Authored,
})
}
purpose => {
self.result.assembly.push(ContextAssembly {
relation: r.id,
instance: prefix.to_vec(),
source,
target,
purpose,
});
}
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
if relations.is_empty()
&& children.iter().all(|id| {
matches!(
self.index.project.elements[id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
})
{
for pair in children.windows(2) {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source: self.vertex(self.address(prefix, pair[0]), true),
target: self.vertex(self.address(prefix, pair[1]), false),
guard: String::new(),
kind: ContextEdgeKind::LegacyOrder,
}) {
break;
}
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
// Intrinsic boundaries are determined before any enclosing caller links.
let edges = &self.result.precedence[start..];
let targets: BTreeSet<_> = edges.iter().map(|e| &e.target).collect();
let sources: BTreeSet<_> = edges.iter().map(|e| &e.source).collect();
let entries = children
.iter()
.map(|id| self.vertex(self.address(prefix, *id), false))
.filter(|v| !targets.contains(v))
.collect();
let exits = children
.iter()
.map(|id| self.vertex(self.address(prefix, *id), true))
.filter(|v| !sources.contains(v))
.collect();
active.remove(&sequence);
(entries, exits)
}
fn cardinality(&mut self) {
// Index authored incidence once; synthetic boundaries never count as controls.
let mut incoming: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut outgoing: BTreeMap<_, Vec<_>> = BTreeMap::new();
for edge in &self.result.precedence {
if edge.kind == ContextEdgeKind::Authored {
incoming.entry(&edge.target.address).or_default().push(edge);
outgoing.entry(&edge.source.address).or_default().push(edge);
}
}
for address in self.result.nodes.keys() {
let ins = incoming.get(address).cloned().unwrap_or_default();
let outs = outgoing.get(address).cloned().unwrap_or_default();
let before = ins.len();
let after = outs.len();
let control = match self.index.project.elements[&address.endpoint.occurrence].shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Control { kind },
..
}) => Some(*kind),
_ => None,
};
let issue = match control {
Some(kind) => { let valid = match kind { ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, ControlKind::Loop => before >= 1 && after >= 2 }; (!valid).then_some(("control_cardinality", format!("{kind:?} has {before} incoming and {after} outgoing authored precedence links in this caller context. Review the intended paths."))) }
None => (before > 1 || after > 1).then_some(("ambiguous_sequence_branch", "This contribution has multiple incoming or outgoing precedence links in this caller context. Review whether explicit merge/join or branching control is needed to describe the intended paths.".into())),
};
if let Some((code, message)) = issue {
self.result.notices.push(ContextNotice {
address: address.clone(),
code,
message,
relations: ins.iter().chain(&outs).filter_map(|e| e.relation).collect(),
});
}
if matches!(control, Some(ControlKind::Choice | ControlKind::Loop)) {
for edge in outs.iter().filter(|e| e.guard.trim().is_empty()) {
self.result.notices.push(ContextNotice { address: address.clone(), code: "missing_sequence_guard", message: "Describe the choice/loop condition for this path, or use an explicit 'else' guard.".into(), relations: edge.relation.into_iter().collect() });
}
}
}
}
fn connectivity(&mut self) {
let mut forward: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut reverse: BTreeMap<_, Vec<_>> = BTreeMap::new();
for edge in &self.result.precedence {
forward
.entry(edge.source.clone())
.or_default()
.push(edge.target.clone());
reverse
.entry(edge.target.clone())
.or_default()
.push(edge.source.clone());
}
let visit = |starts: &[ContextVertex],
graph: &BTreeMap<ContextVertex, Vec<ContextVertex>>| {
let mut pending = starts.to_vec();
let mut seen = BTreeSet::new();
while let Some(vertex) = pending.pop() {
if seen.insert(vertex.clone()) {
pending.extend(graph.get(&vertex).into_iter().flatten().cloned());
}
}
seen
};
let from_start = visit(&self.result.entries, &forward);
let to_end = visit(&self.result.exits, &reverse);
for address in self.result.nodes.keys() {
let entry = self.vertex(address.clone(), false);
let exit = self.vertex(address.clone(), true);
for (missing, code, message) in [
(
!from_start.contains(&entry),
"unreachable_contribution",
"No path from a structural caller entry reaches this contribution in this context.",
),
(
!to_end.contains(&exit),
"nonterminating_contribution",
"No structural caller exit is reachable from this contribution in this context; review the intended loop.",
),
(
self.result.nodes.len() > 1
&& !reverse.contains_key(&entry)
&& !forward.contains_key(&exit),
"isolated_contribution",
"This contribution has no incoming or outgoing precedence path in this caller context; assembly incidence is separate.",
),
] {
if missing {
self.result.notices.push(ContextNotice {
address: address.clone(),
code,
message: message.into(),
relations: vec![],
});
}
}
}
}
}
@@ -0,0 +1,172 @@
use super::*;
fn occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let id = p
.add_element(Kind::Occurrence, Layer::Sa, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn repeated(references: usize, links: usize) -> (Project, Id, Id) {
let mut p = Project::empty();
let root = p
.add_element(Kind::FunctionalChain, Layer::Sa, "Caller", None)
.unwrap();
let procedure = p
.add_element(Kind::FunctionalChain, Layer::Sa, "Reusable procedure", None)
.unwrap();
let mut leaves = Vec::new();
for name in ["A", "B"] {
let action = p
.add_element(Kind::Function, Layer::Sa, name, None)
.unwrap();
leaves.push(occurrence(
&mut p,
procedure,
name,
OccurrenceBody::Action {
action: Some(action),
lifeline: None,
},
));
}
for _ in 0..links {
p.relate(
RelationKind::SequenceLink,
"Authored A to B",
leaves[0],
leaves[1],
);
}
for _ in 0..references {
occurrence(
&mut p,
root,
"Reuse",
OccurrenceBody::SequenceReference {
sequence: Some(procedure),
},
);
}
(p, root, procedure)
}
#[test]
fn edge_work_budget_stops_repeated_expansion_not_just_output_storage() {
// Only ~3,000 possible contribution nodes, but a million authored edge
// instances. The edge budget must stop actual work before later references.
let (p, root, _) = repeated(1_000, 1_000);
let limits = AnalysisLimits::default();
let index = TopologyIndex::with_cache_capacity(&p, 1);
let (first, attempts) = index.analyze_counted(root, limits);
assert_eq!(attempts, limits.edges);
assert_eq!(first.precedence.len(), limits.edges);
assert!(!first.complete);
assert!(first.entries.is_empty() && first.exits.is_empty());
assert!(
first.nodes.len() < 1_000,
"later caller references must not be expanded"
);
assert_eq!(
first
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
assert!(
first
.notices
.iter()
.all(|n| n.code == "context_analysis_limit")
);
let (warm, warm_attempts) = index.analyze_counted(root, limits);
assert_eq!(warm_attempts, attempts);
assert_eq!(warm.nodes, first.nodes);
assert_eq!(warm.precedence, first.precedence);
assert_eq!(warm.notices, first.notices);
drop(warm);
let (uncached, uncached_attempts) =
TopologyIndex::with_cache_capacity(&p, 0).analyze_counted(root, limits);
assert_eq!(uncached_attempts, attempts);
assert_eq!(uncached.precedence, first.precedence);
assert_eq!(uncached.notices, first.notices);
}
#[test]
fn unresolved_authored_edges_consume_work_budget_without_stored_output() {
let (mut p, root, _) = repeated(20, 100);
let missing = Id::nil();
for relation in p.relations.values_mut() {
relation.body.set_endpoints(relation.source(), missing);
}
let (result, attempts) = TopologyIndex::new(&p).analyze_counted(
root,
AnalysisLimits {
nodes: 1_000,
edges: 17,
},
);
assert_eq!(attempts, 17);
assert!(result.precedence.is_empty());
assert_eq!(result.nodes.len(), 3);
assert!(!result.complete);
assert!(result.entries.is_empty() && result.exits.is_empty());
assert_eq!(
result
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
}
#[test]
fn exact_edge_budget_includes_reference_boundaries_and_node_abort_has_no_bypass() {
let (p, root, _) = repeated(1, 2);
let index = TopologyIndex::new(&p);
let (complete, count) = index.analyze_counted(root, AnalysisLimits { nodes: 3, edges: 4 });
assert!(complete.complete);
assert_eq!(count, 4); // two authored edges plus entry and exit connectors
assert_eq!(
complete
.precedence
.iter()
.filter(|e| e.kind == ContextEdgeKind::SyntheticBoundary)
.count(),
2
);
for limits in [
AnalysisLimits { nodes: 3, edges: 3 },
AnalysisLimits {
nodes: 1,
edges: 10,
},
] {
let (partial, count) = index.analyze_counted(root, limits);
assert!(!partial.complete);
assert!(partial.entries.is_empty() && partial.exits.is_empty());
assert!(count <= limits.edges);
assert_eq!(
partial
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
}
}
+268 -16
View File
@@ -1,6 +1,8 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod communication;
pub(crate) use communication::review_endpoint_fact;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionDirection {
@@ -53,6 +55,36 @@ pub struct FieldResolution {
pub field: TransitionField,
pub decision: FieldDecision,
}
/// Technical endpoint choices are scoped to one OA exchange. Participant
/// correspondence remains in `mappings`, never in these port identities.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum CommunicationPortChoice {
Reuse {
port: Id,
},
Create {
id: Id,
alias: String,
name: String,
description: String,
kind: ComponentPortKind,
orientation: ComponentPortOrientation,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommunicationEndpointDecision {
pub source: Id,
pub source_port: CommunicationPortChoice,
pub target_port: CommunicationPortChoice,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperationOmission {
pub source: Id,
pub rationale: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionRequest {
@@ -63,6 +95,10 @@ pub struct TransitionRequest {
pub fields: Vec<TransitionField>,
pub resolutions: Vec<FieldResolution>,
pub destination_diagram: Option<Id>,
#[serde(default)]
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub operation_omissions: Vec<OperationOmission>,
}
impl TransitionRequest {
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
@@ -74,6 +110,8 @@ impl TransitionRequest {
fields: vec![TransitionField::Name, TransitionField::Description],
resolutions: vec![],
destination_diagram: None,
communication_endpoints: vec![],
operation_omissions: vec![],
}
}
}
@@ -177,6 +215,7 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
|| matches!(
k,
Kind::ExchangeItem
| Kind::ExchangeCategory
| Kind::DataType
| Kind::DataField
| Kind::Interface
@@ -202,9 +241,15 @@ fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
return None;
}
if let Some(shared) = e.shared()
&& kind == e.kind()
&& (kind == e.kind() || (kind.is_state() && e.kind().is_state()))
{
let mut shared = shared.clone();
if let SharedBody::State {
kind: state_kind, ..
} = &mut shared
{
*state_kind = kind.state_kind()?;
}
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
*viewpoint = match (*viewpoint, layer) {
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
@@ -249,8 +294,32 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
items: items.clone(),
}
}
(
RelationBody::CommunicationMean {
oriented, items, ..
},
TransitionDirection::OaToSa,
) => RelationBody::BoundaryExchange {
source,
target,
oriented: *oriented,
items: items.clone(),
},
(
RelationBody::BoundaryExchange {
oriented, items, ..
},
TransitionDirection::SaToOa,
) => RelationBody::CommunicationMean {
source,
target,
oriented: *oriented,
items: items.clone(),
},
(
RelationBody::Allocation { .. }
| RelationBody::InterfaceItemAllocation { .. }
| RelationBody::DataAssociation { .. }
| RelationBody::ExchangeAllocation { .. }
| RelationBody::CapabilityInvolvement { .. }
| RelationBody::CapabilityInclude { .. }
@@ -259,6 +328,7 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
| RelationBody::MissionInvolvement { .. }
| RelationBody::MissionCapability { .. }
| RelationBody::SequenceLink { .. }
| RelationBody::ContextualLink { .. }
| RelationBody::ChainInvolvement { .. }
| RelationBody::BoundaryExchange { .. }
| RelationBody::InterfaceProvision { .. }
@@ -310,7 +380,7 @@ fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind {
_ => TraceKind::Manual,
},
_ => {
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if exchange_counterparts(a.kind(), b.kind()))
{
TraceKind::Exchange
} else {
@@ -341,11 +411,12 @@ fn sa_port_omissions(p: &Project) -> BTreeSet<Id> {
.collect();
loop {
let before = omitted.len();
for r in p
.relations
.values()
.filter(|r| r.kind() != RelationKind::FunctionalExchange)
{
for r in p.relations.values().filter(|r| {
!matches!(
r.kind(),
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
)
}) {
if crate::references::relation_references(r)
.iter()
.any(|id| omitted.contains(id))
@@ -387,11 +458,30 @@ pub fn plan_transition(
commands: vec![],
};
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
let omitted = if request.direction == TransitionDirection::SaToOa {
let mut omitted = if request.direction == TransitionDirection::SaToOa {
sa_port_omissions(project)
} else {
BTreeSet::new()
};
// An explicit existing Legacy counterpart can be compared and reviewed
// without inventing an OA typed port. Only this port is retained: dependent
// SA implementation records and new counterpart requests stay omitted.
for mapping in &request.mappings {
if project
.elements
.get(&mapping.source)
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
&& mapping
.target
.and_then(|target| project.elements.get(&target))
.is_some_and(|e| {
e.layer() == Layer::Oa
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
})
{
omitted.remove(&mapping.source);
}
}
let mut reported_omissions = BTreeSet::new();
if selected.len() != request.sources.len() {
conflict(
@@ -420,17 +510,45 @@ pub fn plan_transition(
continue;
}
if let Some(e) = project.elements.get(&id) {
selected.extend(crate::references::element_references(e));
let mut dependencies = e.clone();
if request.direction == TransitionDirection::SaToOa
&& let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = dependencies.shared_mut()
{
*operation = None;
}
selected.extend(crate::references::element_references(&dependencies));
// Allocations are owned by interfaces. Independent data
// associations travel only when both endpoint types are
// already required; they do not expand the dictionary.
if matches!(e.kind(), Kind::Interface | Kind::DataType) {
let contracts: Vec<_> = project
.relations
.values()
.filter_map(|r| {
((r.kind() == RelationKind::InterfaceItemAllocation
&& r.source() == id)
|| (r.kind() == RelationKind::DataAssociation
&& selected.contains(&r.source())
&& selected.contains(&r.target())))
.then_some(r.id)
})
.collect();
selected.extend(contracts);
}
// Contextual links are owned by the process/chain shared by
// their endpoint involvements, independently of list order.
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
selected.extend(project.relations.values().filter_map(|relation| {
(relation.kind() == RelationKind::SequenceLink
&& project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
== Some(id))
(relation.contextual_owner() == Some(id)
|| (relation.kind() == RelationKind::SequenceLink
&& project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
== Some(id)))
.then_some(relation.id)
}));
}
@@ -454,6 +572,15 @@ pub fn plan_transition(
}
} else if let Some(r) = project.relations.get(&id) {
if request.direction == TransitionDirection::SaToOa
&& r.kind() == RelationKind::BoundaryExchange
{
selected.extend(
[r.source(), r.target()].into_iter().filter_map(|port| {
project.elements.get(&port).and_then(|e| e.parent)
}),
);
selected.extend(r.body.items());
} else if request.direction == TransitionDirection::SaToOa
&& let RelationBody::FunctionalExchange {
source,
target,
@@ -487,6 +614,67 @@ pub fn plan_transition(
false
} else { true }
});
let mut operation_omissions = BTreeSet::new();
for decision in &request.operation_omissions {
if request.direction != TransitionDirection::SaToOa
|| !selected.contains(&decision.source)
|| !matches!(
project
.elements
.get(&decision.source)
.and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
message_kind: MessageKind::Call | MessageKind::Asynchronous,
..
},
..
})
)
|| decision.rationale.trim().is_empty()
|| !validation::valid_text(&decision.rationale)
|| !operation_omissions.insert(decision.source)
{
conflict(
&mut plan,
Some(decision.source),
None,
None,
"Operation omission needs one selected SA invocation, SA→OA direction, and a nonempty rationale; irrelevant or duplicate decisions reject.",
);
} else {
plan.warnings.push(format!(
"Reviewed operation omission: {} ({}): {}. Source contract is preserved.",
project.named(decision.source),
decision.source,
decision.rationale
));
}
}
if request.direction == TransitionDirection::SaToOa {
for source in &selected {
if matches!(
project.elements.get(source).and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
..
},
..
})
) && !operation_omissions.contains(source)
{
conflict(
&mut plan,
Some(*source),
None,
None,
"This technical operation has no OA operation slot. Review an explicit per-invocation omission with rationale; Reply derives the Call's decision.",
);
}
}
}
let mut choices = BTreeMap::new();
for mapping in &request.mappings {
if choices.insert(mapping.source, mapping).is_some() {
@@ -650,6 +838,8 @@ pub fn plan_transition(
}
}
let mut working = project.clone();
let communication_pairs =
communication::prepare_ports(project, &mut working, &targets, &mut plan);
let ordered_creations: Vec<_> = selected
.iter()
.filter(|id| creating.contains(id) && project.elements.contains_key(id))
@@ -678,6 +868,15 @@ pub fn plan_transition(
let mut created = e.clone();
created.id = target;
created.body = body;
if request.direction == TransitionDirection::SaToOa
&& operation_omissions.contains(source)
&& let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = created.shared_mut()
{
*operation = None;
}
created.alias = working.fresh_alias(
&format!(
"{}_{}",
@@ -719,6 +918,25 @@ pub fn plan_transition(
let mut created = r.clone();
created.id = target;
created.body = body;
// SA technical ports project through their reviewed owners; never
// place a port→participant pair in the global correspondence map.
if request.direction == TransitionDirection::SaToOa
&& r.kind() == RelationKind::BoundaryExchange
{
let owners = [r.source(), r.target()]
.map(|port| project.elements.get(&port).and_then(|e| e.parent));
if let [Some(a), Some(b)] = owners {
created.body.set_endpoints(a, b);
} else {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Boundary endpoints need participant owners before communication transition.",
);
}
}
created.alias = working.fresh_alias(
&format!(
"{}_{}",
@@ -733,6 +951,21 @@ pub fn plan_transition(
*id = *mapped;
}
});
if request.direction == TransitionDirection::OaToSa
&& r.kind() == RelationKind::CommunicationMean
{
if let Some((a, b)) = communication_pairs.get(source) {
created.body.set_endpoints(*a, *b);
} else {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Choose an explicit SA component-port pair for this communication means; participant mappings cannot stand in for ports.",
);
}
}
for dependency in crate::references::relation_references(&created) {
if dependency.is_nil()
&& request.direction == TransitionDirection::OaToSa
@@ -907,6 +1140,7 @@ pub fn plan_transition(
.unwrap_or(TraceBaseline {
oa: current_oa.clone(),
sa: current_sa.clone(),
semantic: None,
});
for field in &reviewed_fields {
set(&mut baseline.oa, *field, value(&current_oa, *field).clone());
@@ -1061,6 +1295,11 @@ pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Ki
}
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
// State kind is a reviewed structural facet of one state family. An existing
// counterpart remains selectable after either side changes its vertex kind.
if source.is_state() && target.is_state() {
return true;
}
if matches!(
(direction, source, target),
(
@@ -1088,9 +1327,22 @@ fn compatible_existing(
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
_ => match (p.relations.get(&source), p.relations.get(&target)) {
(Some(a), Some(b)) => {
mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
(a.kind() == RelationKind::BoundaryExchange
&& b.kind() == RelationKind::BoundaryExchange)
|| mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
}
_ => false,
},
}
}
pub(crate) fn exchange_counterparts(oa: RelationKind, sa: RelationKind) -> bool {
matches!(
(oa, sa),
(RelationKind::Interaction, RelationKind::FunctionalExchange)
| (
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange
)
)
}
@@ -0,0 +1,210 @@
use super::*;
fn port_id(choice: &CommunicationPortChoice) -> Id {
match choice {
CommunicationPortChoice::Reuse { port } => *port,
CommunicationPortChoice::Create { id, .. } => *id,
}
}
pub(super) fn prepare_ports(
original: &Project,
working: &mut Project,
targets: &BTreeMap<Id, Id>,
plan: &mut TransitionPlan,
) -> BTreeMap<Id, (Id, Id)> {
let mut pairs = BTreeMap::new();
for decision in plan.request.communication_endpoints.clone() {
let result = (|| -> Result<(Id, Id), String> {
if plan.request.direction != TransitionDirection::OaToSa {
return Err(
"SA endpoint choices apply only to OA→SA communication transition.".into(),
);
}
let means = original
.relations
.get(&decision.source)
.filter(|r| r.kind() == RelationKind::CommunicationMean)
.ok_or("Endpoint decision must select an OA communication means.")?;
if !plan.request.sources.contains(&means.id) && !targets.contains_key(&means.id) {
return Err("Endpoint decision is outside this transition selection.".into());
}
if pairs.contains_key(&decision.source) {
return Err("Choose one component-port pair per communication means.".into());
}
let mut pending = vec![];
for (participant, choice) in [
(means.source(), &decision.source_port),
(means.target(), &decision.target_port),
] {
let owner = targets.get(&participant).copied().ok_or("Review each OA participant's SA counterpart before selecting its component port.")?;
if working.elements.get(&owner).is_some_and(|e| {
e.layer() != Layer::Sa || !matches!(e.kind(), Kind::System | Kind::SystemActor)
}) {
return Err(
"Component-port owner must be the reviewed SA participant counterpart."
.into(),
);
}
match choice {
CommunicationPortChoice::Reuse { port } => {
let e = working
.elements
.get(port)
.ok_or("Selected component port does not exist.")?;
if e.parent != Some(owner)
|| e.layer() != Layer::Sa
|| !matches!(
crate::ports::port_contract(e),
Some(PortContract::Component { .. })
)
{
return Err("Selected component port must belong to the reviewed SA participant and have an explicit component contract.".into());
}
}
CommunicationPortChoice::Create {
id,
alias,
name,
description,
kind,
orientation,
} => {
if id.is_nil()
|| working.contains(*id)
|| pending.iter().any(|e: &Element| e.id == *id)
{
return Err("New component-port identity must be unused; choose Reuse for an existing port.".into());
}
pending.push(Element {
id: *id,
alias: alias.clone(),
name: name.clone(),
description: description.clone(),
parent: Some(owner),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Component {
kind: *kind,
orientation: *orientation,
},
legacy: None,
},
),
});
}
}
}
let pair = (
port_id(&decision.source_port),
port_id(&decision.target_port),
);
if pair.0 == pair.1 || targets.get(&means.source()) == targets.get(&means.target()) {
return Err("Communication counterparts must have different participant owners; review the participant boundary mapping.".into());
}
for e in pending {
working.elements.insert(e.id, e.clone());
plan.commands.push(Command::CreateElement(e));
}
Ok(pair)
})();
match result {
Ok(pair) => {
pairs.insert(decision.source, pair);
}
Err(message) => conflict(
plan,
Some(decision.source),
targets.get(&decision.source).copied(),
None,
message,
),
}
}
pairs
}
/// Adapt only the transferred endpoint value. Reviewed baseline facts continue
/// to retain the actual participant/port identities on their respective sides.
pub(crate) fn review_endpoint_fact(
project: &Project,
request: &TransitionRequest,
map: &BTreeMap<Id, Id>,
source: Id,
target: Id,
fact: &FacetReview,
) -> Result<Option<FacetReview>, String> {
let (Some(a), Some(b)) = (
project.relations.get(&source),
project.relations.get(&target),
) else {
return Ok(None);
};
let forward =
a.kind() == RelationKind::CommunicationMean && b.kind() == RelationKind::BoundaryExchange;
let reverse =
a.kind() == RelationKind::BoundaryExchange && b.kind() == RelationKind::CommunicationMean;
if !forward && !reverse {
return Ok(None);
}
let (endpoint, is_source) = match fact {
FacetReview::Relation(RelationReview::Source(_)) => (a.source(), true),
FacetReview::Relation(RelationReview::Target(_)) => (a.target(), false),
_ => return Ok(None),
};
let mapped = if forward {
let decision = request.communication_endpoints.iter().find(|d| d.source == source)
.ok_or("Taking an OA endpoint requires this exchange's explicit SA component-port decision.")?;
let port = port_id(if is_source {
&decision.source_port
} else {
&decision.target_port
});
let expected_owner = map.get(&endpoint).ok_or(
"Review the participant counterpart before taking its communication endpoint.",
)?;
let e = project
.elements
.get(&port)
.ok_or("Selected communication endpoint port does not exist.")?;
if e.parent != Some(*expected_owner)
|| e.layer() != Layer::Sa
|| !matches!(
crate::ports::port_contract(e),
Some(PortContract::Component { .. })
)
{
return Err(
"Selected component port disagrees with the reviewed participant counterpart."
.into(),
);
}
port
} else {
let owner = project
.elements
.get(&endpoint)
.and_then(|e| e.parent)
.ok_or("SA boundary port needs a participant owner.")?;
let participant = *map.get(&owner).ok_or(
"Review the SA port owner's OA participant counterpart before taking this endpoint.",
)?;
if !project.elements.get(&participant).is_some_and(|e| {
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
}) {
return Err("Boundary endpoint owner must map to an OA entity or actor.".into());
}
participant
};
let mut result = fact.clone();
if let FacetReview::Relation(RelationReview::Source(values) | RelationReview::Target(values)) =
&mut result
{
match request.direction.source_layer() {
Layer::Oa => values.oa = Observed::Value(mapped),
Layer::Sa => values.sa = Observed::Value(mapped),
}
}
Ok(Some(result))
}
+456 -117
View File
@@ -2,6 +2,8 @@ use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod coverage;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Error,
@@ -179,6 +181,8 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
pub fn validate(p: &Project) -> Vec<Issue> {
let mut out = Vec::new();
let coverage = coverage::Index::new(p);
let topology_index = crate::topology_context::TopologyIndex::new(p);
if p.schema_version != SCHEMA_VERSION {
error(
&mut out,
@@ -346,19 +350,15 @@ pub fn validate(p: &Project) -> Vec<Issue> {
);
}
if matches!(e.kind(), Kind::Capability | Kind::SystemCapability)
&& !p.relations.values().any(|r| {
matches!(
r.kind(),
RelationKind::CapabilityInvolvement | RelationKind::ChainInvolvement
) && r.target() == e.id
})
&& !coverage.grouping.contains(&e.id)
&& !coverage.supported_capabilities.contains(&e.id)
{
warning(
&mut out,
e.id,
"unsupported_capability",
"involvement",
"Connect the capability to involved participants, work, or a process/chain.",
"Connect the capability to involved participants, work, a process/chain, or an explicit scenario context.",
);
}
if let Some(steps) = e.steps() {
@@ -384,72 +384,31 @@ pub fn validate(p: &Project) -> Vec<Issue> {
"Add ordered occurrences.",
);
}
validate_sequence(p, e, steps, &mut out);
validate_sequence(p, e, steps, &topology_index, &mut out);
}
if let Some(shared) = e.shared() {
match shared {
SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals },
} => {
let mut seen = BTreeSet::new();
for literal in literals {
text(&mut out, e.id, "literals", literal);
if !seen.insert(literal) {
error(
&mut out,
e.id,
"duplicate_literal",
"literals",
"Enumeration literals must be unique.",
);
}
}
}
SharedBody::DataField {
type_ref,
minimum,
maximum,
unit,
} => {
if maximum.is_some_and(|max| max < *minimum) {
SharedBody::ExchangeCategory { family, exchanges } => {
for issue in crate::view_options::validate_category(
p,
e.layer(),
e.parent,
*family,
exchanges,
) {
error(
&mut out,
e.id,
"invalid_multiplicity",
"maximum",
"Maximum multiplicity must be at least minimum, or unbounded.",
);
}
text(&mut out, e.id, "unit", unit);
if let TypeRef::Named(id) = type_ref {
expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType);
}
if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id))
&& parent.kind() == Kind::DataType
&& !matches!(
parent.shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Record
})
)
{
error(
&mut out,
e.id,
"field_owner",
"parent",
"Fields under a data type require a Record type.",
"exchange_category",
issue.field,
issue.message,
);
}
}
SharedBody::Interface { items } => {
check_ids(&mut out, e.id, "items", items);
for id in items {
expect_element(p, &mut out, e.id, *id, "items", |k| {
k == Kind::ExchangeItem
});
}
}
SharedBody::DataType { .. }
| SharedBody::DataField { .. }
| SharedBody::Interface { .. }
| SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out),
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
SharedBody::Scenario {
viewpoint,
@@ -642,10 +601,12 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, r.id, "name", &r.name);
text(&mut out, r.id, "description", &r.description);
validate_relation(p, r, &mut out);
validate_relation(p, r, &topology_index, &mut out);
crate::data::validate_relation(p, r, &mut out);
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
validate_relation_context(p, r, &mut out);
}
crate::data::validate_record_cycles(p, &mut out);
for (key, d) in &p.diagrams {
if *key != d.id {
error(
@@ -658,6 +619,14 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
crate::view_options::validate_options(p, d.layer, &d.view_options)
};
for issue in option_issues {
error(&mut out, d.id, "view_options", issue.field, issue.message);
}
if let Some(machine) = d.projection.state_machine()
&& !p
.elements
@@ -932,6 +901,19 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, t.id, "baseline.name", &props.name);
text(&mut out, t.id, "baseline.description", &props.description);
}
if let Some(semantic) = &b.semantic
&& let Err(errors) = crate::semantic::validate_semantic_baseline(semantic)
{
for message in errors {
error(
&mut out,
t.id,
"semantic_baseline",
"baseline.semantic",
message,
);
}
}
}
match crate::transitions::trace_drift(p, t) {
crate::transitions::Drift::Unchanged => {}
@@ -964,12 +946,62 @@ pub fn validate(p: &Project) -> Vec<Issue> {
);
}
}
if !out.iter().any(|issue| issue.severity == Severity::Error) {
coverage.validate(p, &mut out);
}
out
}
fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec<Issue>) {
fn validate_sequence(
p: &Project,
e: &Element,
steps: &[Id],
topology: &crate::topology_context::TopologyIndex<'_>,
out: &mut Vec<Issue>,
) {
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return;
}
if topology.has_contextual_links(e.id) {
let analysis = topology.analyze(e.id, crate::topology_context::AnalysisLimits::default());
for notice in analysis.notices {
let breadcrumb = notice
.address
.endpoint
.references
.iter()
.chain(std::iter::once(&notice.address.endpoint.occurrence))
.map(|id| format!("{} ({id})", p.named(*id)))
.collect::<Vec<_>>()
.join(" / ");
warning(
out,
e.id,
notice.code,
"steps",
format!("Caller '{}', {breadcrumb}: {}", e.name, notice.message),
);
}
if analysis.complete {
for (code, label, count) in [
("sequence_starts", "start", analysis.entries.len()),
("sequence_ends", "end", analysis.exits.len()),
] {
if count != 1 {
warning(
out,
e.id,
code,
"steps",
format!(
"Caller '{}' has {count} structural {label} boundaries; review the intended independent paths.",
e.name
),
);
}
}
}
return;
}
let links: Vec<_> = p
.relations
.values()
@@ -1297,40 +1329,149 @@ pub fn semantic_representation_compatible(
pub fn lifeline_matches_endpoint(p: &Project, line: Id, endpoint: Id) -> Option<bool> {
semantic_representation_compatible(p, represented(p, line)?, endpoint)
}
/// Effective semantic direction, including a Reply that reuses its Call exchange.
/// An incomplete reply correlation and a local timer have no inferred endpoints.
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
/// A single shared answer for scenario admission, drawing, and endpoint pickers.
/// Incomplete or ambiguous bindings never choose an arbitrary direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageEndpointResolution {
NotApplicable,
Incomplete,
Resolved { source: Id, target: Id },
Ambiguous,
Conflict,
}
pub fn resolve_message_endpoints(p: &Project, body: &OccurrenceBody) -> MessageEndpointResolution {
use MessageEndpointResolution as R;
let OccurrenceBody::Exchange {
exchange: Some(exchange),
message_kind,
reply_to,
sender,
receiver,
..
} = body
else {
return None;
return R::NotApplicable;
};
if matches!(message_kind, MessageKind::Timer { .. }) {
return None;
return R::NotApplicable;
}
let Some(relation) = p.relations.get(exchange) else {
return R::Incomplete;
};
let mut candidates = vec![relation.body.endpoints()];
// A mapped unoriented SA boundary retains the OA communication's possible
// directions. Fixed oriented/legacy exchange behavior remains unchanged.
let communication = matches!(
relation.body,
RelationBody::CommunicationMean { .. }
| RelationBody::BoundaryExchange {
oriented: false,
..
}
);
if matches!(
relation.body,
RelationBody::CommunicationMean {
oriented: false,
..
} | RelationBody::BoundaryExchange {
oriented: false,
..
}
) {
candidates.push((relation.target(), relation.source()));
}
if matches!(
relation.body,
RelationBody::BoundaryExchange {
oriented: false,
..
}
) {
candidates.retain(|(source, target)| {
match (
p.elements.get(source).and_then(crate::ports::port_contract),
p.elements.get(target).and_then(crate::ports::port_contract),
) {
(
Some(a @ PortContract::Component { .. }),
Some(b @ PortContract::Component { .. }),
) => {
crate::ports::component_allows(a, true)
&& crate::ports::component_allows(b, false)
}
// Legacy direction contracts remain fixed even for an
// unoriented declaration; do not reinterpret old ports.
_ => (*source, *target) == relation.body.endpoints(),
}
});
}
let relation = p.relations.get(exchange)?;
if message_kind == &MessageKind::Reply {
let Some(reply_to) = reply_to else {
return R::Incomplete;
};
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
call_body @ OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
exchange: Some(call_exchange),
..
},
..
}) = p.elements.get(&(*reply_to)?).and_then(Element::shared)
}) = p.elements.get(reply_to).and_then(Element::shared)
else {
return None;
return R::Incomplete;
};
if call_exchange == exchange {
return Some((relation.target(), relation.source()));
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
return R::Incomplete;
};
candidates = vec![(target, source)];
} else if communication {
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
return R::Incomplete;
};
let owner = |id| {
p.elements
.get(&id)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id)
};
candidates.retain(|(a, b)| {
semantic_representation_compatible(p, owner(*a), owner(target)) == Some(true)
&& semantic_representation_compatible(p, owner(*b), owner(source)) == Some(true)
});
}
}
Some(relation.body.endpoints())
if communication {
candidates.retain(|(a, b)| {
sender.is_none_or(|line| lifeline_matches_endpoint(p, line, *a) != Some(false))
&& receiver.is_none_or(|line| lifeline_matches_endpoint(p, line, *b) != Some(false))
});
if candidates.is_empty() {
return R::Conflict;
}
if sender.and_then(|line| represented(p, line)).is_none()
|| receiver.and_then(|line| represented(p, line)).is_none()
{
return R::Incomplete;
}
if candidates.len() > 1 {
return R::Ambiguous;
}
}
let Some((source, target)) = candidates.first().copied() else {
return R::Conflict;
};
R::Resolved { source, target }
}
/// Compatibility adapter: only a resolved direction has inferred endpoints.
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
match resolve_message_endpoints(p, body) {
MessageEndpointResolution::Resolved { source, target } => Some((source, target)),
_ => None,
}
}
/// Only report order guaranteed by a common sequence or an enclosing branch path.
/// A call in one alternative does not become an unconditional call after the fragment.
@@ -1436,23 +1577,10 @@ fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet<Id> {
}
/// Fields carried by an exchange, including fields of nested named records.
pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet<Id> {
let mut pending = relation.body.items().to_vec();
let mut seen = BTreeSet::new();
let mut fields = BTreeSet::new();
while let Some(owner) = pending.pop() {
if !seen.insert(owner) {
continue;
}
for field in p.children(owner) {
if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() {
fields.insert(field.id);
if let TypeRef::Named(id) = type_ref {
pending.push(*id);
}
}
}
}
fields
crate::data::carried_field_paths(p, relation)
.into_iter()
.map(|f| f.field)
.collect()
}
fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool {
match body {
@@ -1474,6 +1602,7 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
receiver,
reply_to,
values,
..
} = body
else {
return;
@@ -1496,7 +1625,9 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
ScenarioViewpoint::Functional => relation.kind() == RelationKind::FunctionalExchange,
ScenarioViewpoint::ParticipantInteraction => matches!(
relation.kind(),
RelationKind::Interaction | RelationKind::BoundaryExchange
RelationKind::Interaction
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
),
ScenarioViewpoint::BoundaryInterface => matches!(
relation.kind(),
@@ -1683,36 +1814,191 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
}
}
}
let allowed = exchange
.and_then(|id| p.relations.get(&id))
.map(|r| carried_fields(p, r));
let relation = exchange.and_then(|id| p.relations.get(&id));
let operation = crate::operations::resolve(p, e.id);
let signature = match &operation {
Ok(crate::operations::OperationResolution::Selected(selected)) => {
if selected.transmission == TransmissionProtocol::Unspecified {
warning(
out,
e.id,
"operation_transmission_unspecified",
"operation",
"The selected operation transmission protocol is unspecified.",
);
}
Some(crate::operations::signature_relation(selected))
}
Ok(crate::operations::OperationResolution::Unresolved { reason, .. }) => {
warning(out, e.id, "operation_unresolved", "operation", reason);
None
}
Err(problem) => {
error(out, e.id, problem.code, "operation", &problem.message);
None
}
_ => None,
};
let value_relation = match &operation {
Ok(crate::operations::OperationResolution::Ordinary) => relation,
_ => signature.as_ref(),
};
if message_kind == &MessageKind::Call
&& relation.is_some_and(|r| {
!r.body
.items()
.iter()
.any(|id| crate::data::item_mechanism(p, *id) == Some(ExchangeMechanism::Operation))
})
{
warning(
out,
e.id,
"call_without_operation",
"exchange",
"This generic call carries no Operation item; define an operation contract when appropriate.",
);
}
let mut fields = BTreeSet::new();
for value in values {
expect_element(p, out, e.id, value.field, "values.field", |kind| {
kind == Kind::DataField
});
for hop in &value.path {
expect_element(p, out, e.id, *hop, "values.path", |kind| {
kind == Kind::DataField
});
}
text(out, e.id, "values.expression", &value.expression);
if !fields.insert(value.field) {
if !fields.insert((&value.path, value.field)) {
error(
out,
e.id,
"duplicate_message_value",
"values",
"Each carried field has at most one contextual value annotation in a message.",
"Each qualified carried field has at most one contextual value annotation.",
);
}
if allowed
.as_ref()
.is_some_and(|allowed| !allowed.contains(&value.field))
{
if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH {
error(
out,
e.id,
"message_value_field",
"values.field",
"Value annotations must reference fields carried by this message's exchange, including nested record fields.",
"message_value_path_limit",
"values.path",
"The value path exceeds the bounded authoring depth.",
);
}
if matches!(
operation,
Ok(crate::operations::OperationResolution::Unresolved { .. })
) && let Some(owner) = value
.path
.first()
.or(Some(&value.field))
.and_then(|id| p.elements.get(id))
.and_then(|field| field.parent)
{
let path_context = Relation {
id: e.id,
alias: String::new(),
name: String::new(),
description: String::new(),
body: RelationBody::Interaction {
source: e.id,
target: e.id,
items: vec![owner],
},
};
if crate::data::carried_value_path(p, &path_context, &value.path, value.field).is_none()
{
error(
out,
e.id,
"message_value_path",
"values.path",
"Even while the operation is unresolved, a value path must follow existing Value record fields in order.",
);
}
}
if let Some(r) = value_relation {
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
let permitted = match message_kind {
MessageKind::Asynchronous if signature.is_some() => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Call => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Reply => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::Out
| ParameterDirection::InOut
| ParameterDirection::Return
),
_ => true,
};
if !permitted {
error(
out,
e.id,
"message_parameter_phase",
"values.path",
"Call values address In/InOut parameters; replies address Out/InOut/Return parameters.",
);
}
if path.direction == ParameterDirection::Unspecified {
warning(
out,
e.id,
"message_parameter_direction",
"values.path",
"The containing operation parameter has no direction yet.",
);
}
}
} else {
let legacy = value
.path
.is_empty()
.then(|| crate::data::resolve_legacy_field_path(p, r, value.field).ok())
.flatten();
if matches!(
legacy,
Some(
crate::data::LegacyFieldPathResolution::Unique(_)
| crate::data::LegacyFieldPathResolution::Ambiguous
)
) {
warning(
out,
e.id,
if legacy == Some(crate::data::LegacyFieldPathResolution::Ambiguous) {
"message_value_ambiguous_path"
} else {
"message_value_missing_path"
},
"values.path",
"This legacy nested field annotation needs an explicit containing parameter path; its expression is preserved.",
);
} else {
error(
out,
e.id,
"message_value_field",
"values.path",
"The qualified field must be carried by the exchange, following Value record fields only.",
);
}
}
}
if value.expression.trim().is_empty() {
warning(
out,
@@ -1887,6 +2173,7 @@ fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mu
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
) => {}
_ => error(
out,
@@ -2082,7 +2369,24 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
false
}
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
fn validate_relation(
p: &Project,
r: &Relation,
topology: &crate::topology_context::TopologyIndex<'_>,
out: &mut Vec<Issue>,
) {
if let RelationBody::ContextualLink {
owner,
source,
target,
purpose,
} = &r.body
{
if let Err(problem) = topology.validate_link(*owner, source, target, purpose) {
error(out, r.id, problem.code, "body", problem.message);
}
return;
}
let (source, target) = r.body.endpoints();
for id in crate::references::relation_references(r) {
// Most relation references name elements. Avoid constructing all model
@@ -2101,18 +2405,20 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
if let RelationBody::ExchangeAllocation { .. } = r.body {
match (p.relations.get(&source), p.relations.get(&target)) {
(Some(a), Some(b))
if a.kind() == RelationKind::BoundaryExchange
if ((a.kind() == RelationKind::BoundaryExchange
&& matches!(
b.kind(),
RelationKind::FunctionalExchange | RelationKind::Interaction
)
))
|| (a.kind() == RelationKind::CommunicationMean
&& b.kind() == RelationKind::Interaction))
&& p.layer_of(source) == p.layer_of(target) => {}
_ => error(
out,
r.id,
"exchange_allocation",
"endpoints",
"Exchange allocation relates a boundary exchange to a work exchange in the same layer.",
"Exchange allocation relates communication means to an OA interaction, or a boundary exchange to a work exchange, in the same layer.",
),
};
validate_exchange_allocation(p, r, out);
@@ -2130,7 +2436,11 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
};
if a.layer() != b.layer()
|| !r.kind().permits(a.kind(), b.kind())
|| (a.id == b.id && r.kind() != RelationKind::StateTransition)
|| (a.id == b.id
&& !matches!(
r.kind(),
RelationKind::StateTransition | RelationKind::DataAssociation
))
{
error(
out,
@@ -2359,7 +2669,7 @@ pub fn trace_permits(p: &Project, t: &Trace) -> bool {
_ => false,
},
_ => {
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&crate::transitions::exchange_counterparts(a.kind(), b.kind()))
}
}
}
@@ -2381,8 +2691,10 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option<Id
owner.layer() == layer
&& parent_permits(owner.kind(), child_kind)
&& match owner.shared() {
Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => {
*definition == DataTypeDefinition::Record
Some(SharedBody::DataType { definition, .. })
if child_kind == Kind::DataField =>
{
matches!(definition, DataTypeDefinition::Record { .. })
}
Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => {
matches!(
@@ -2452,7 +2764,12 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b
*transition_kind = TransitionKind::Local;
}
let mut issues = vec![];
validate_relation(p, &candidate, &mut issues);
validate_relation(
p,
&candidate,
&crate::topology_context::TopologyIndex::new(p),
&mut issues,
);
validate_relation_context(p, &candidate, &mut issues);
if kind == RelationKind::StateTransition {
for endpoint in [source, target] {
@@ -2482,7 +2799,12 @@ pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool)
},
};
let mut issues = vec![];
validate_relation(p, &candidate, &mut issues);
validate_relation(
p,
&candidate,
&crate::topology_context::TopologyIndex::new(p),
&mut issues,
);
validate_relation_context(p, &candidate, &mut issues);
!issues.iter().any(|i| i.severity == Severity::Error)
}
@@ -2698,6 +3020,23 @@ fn validate_occurrence_context(
OccurrenceBody::Exchange {
sender, receiver, ..
} => {
match resolve_message_endpoints(p, body) {
MessageEndpointResolution::Conflict => error(
out,
e.id,
"scenario_endpoint",
"sender",
"Sender and receiver must agree with one permitted communication direction and the correlated Call.",
),
MessageEndpointResolution::Ambiguous => warning(
out,
e.id,
"ambiguous_message_direction",
"sender",
"Bind explicit participant lifelines to resolve the communication direction; shared roles allow both directions.",
),
_ => {}
}
if let Some((source, target)) = message_endpoints(p, body) {
check(out, *sender, Some(source), "sender");
check(out, *receiver, Some(target), "receiver");
+174
View File
@@ -0,0 +1,174 @@
//! Advisory presence of explicit behavior evidence, never inferred execution.
use super::*;
#[derive(Default)]
pub(super) struct Index {
pub grouping: BTreeSet<Id>,
pub supported_capabilities: BTreeSet<Id>,
contextual_capabilities: BTreeSet<Id>,
supported_missions: BTreeSet<Id>,
}
impl Index {
pub fn new(p: &Project) -> Self {
let mut index = Self::default();
for element in p.elements.values() {
if let Some(parent) = element.parent.and_then(|id| p.elements.get(&id))
&& parent.kind() == element.kind()
&& parent.layer() == element.layer()
{
index.grouping.insert(parent.id);
}
if let Some(SharedBody::Scenario {
context: Some(id), ..
}) = element.shared()
&& p.elements.get(id).is_some_and(|context| {
matches!(context.kind(), Kind::Capability | Kind::SystemCapability)
&& context.layer() == element.layer()
})
{
index.contextual_capabilities.insert(*id);
index.supported_capabilities.insert(*id);
}
}
for relation in p.relations.values() {
let (Some(source), Some(target)) = (
p.elements.get(&relation.source()),
p.elements.get(&relation.target()),
) else {
continue;
};
if source.layer() != target.layer()
|| !relation.kind().permits(source.kind(), target.kind())
{
continue;
}
match relation.kind() {
RelationKind::CapabilityInvolvement => {
index.supported_capabilities.insert(target.id);
}
RelationKind::ChainInvolvement => {
index.supported_capabilities.insert(target.id);
index.contextual_capabilities.insert(target.id);
}
RelationKind::MissionCapability => {
index.supported_missions.insert(source.id);
}
_ => {}
}
}
index
}
pub fn validate(&self, p: &Project, out: &mut Vec<Issue>) {
let (work, exchanges) = included_evidence(p);
for element in p.elements.values() {
if element.kind().is_work()
&& !self.grouping.contains(&element.id)
&& !work.contains(&element.id)
{
warning(
out,
element.id,
"work_without_behavior_context",
"",
"Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence.",
);
}
if matches!(element.kind(), Kind::OperationalMission | Kind::Mission)
&& !self.supported_missions.contains(&element.id)
{
warning(
out,
element.id,
"mission_without_capability",
"",
"Connect this mission to a capability that delivers it.",
);
}
if matches!(element.kind(), Kind::Capability | Kind::SystemCapability)
&& !self.grouping.contains(&element.id)
&& self.supported_capabilities.contains(&element.id)
&& !self.contextual_capabilities.contains(&element.id)
{
warning(
out,
element.id,
"capability_without_behavior_context",
"",
"Add an explicit behavior context: link a process/chain with Chain involvement, or set a scenario's Context to this capability.",
);
}
}
for relation in p.relations.values().filter(|r| work_exchange(r.kind())) {
let mut non_leaf = false;
for (field, endpoint) in [("source", relation.source()), ("target", relation.target())]
{
if self.grouping.contains(&endpoint) {
non_leaf = true;
warning(
out,
relation.id,
"non_leaf_work_exchange",
field,
"Choose the leaf activity/function responsible for this exchange; keep any typed port owned by that function.",
);
}
}
if !non_leaf && !exchanges.contains(&relation.id) {
warning(
out,
relation.id,
"exchange_without_behavior_context",
"",
"Use this exchange in a process/chain or scenario.",
);
}
}
}
}
fn work_exchange(kind: RelationKind) -> bool {
matches!(
kind,
RelationKind::Interaction | RelationKind::FunctionalExchange
)
}
/// Only ordered, owned occurrences contribute. Each identity is visited once,
/// including reused containers; this remains bounded even on malformed drafts.
fn included_evidence(p: &Project) -> (BTreeSet<Id>, BTreeSet<Id>) {
let crate::view_options::BehaviorEvidence { work, exchanges } =
crate::view_options::all_behavior_evidence(p);
(work, exchanges)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn malformed_self_reuse_is_bounded_even_without_structural_gate() {
let mut p = Project::empty();
let scenario = p
.add_element(Kind::Scenario, Layer::Oa, "Self reuse", None)
.unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Cycle", Some(scenario))
.unwrap();
if let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get_mut(&occurrence).unwrap().shared_mut()
{
*body = OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![],
};
}
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(occurrence);
assert_eq!(included_evidence(&p), (BTreeSet::new(), BTreeSet::new()));
}
}
+733
View File
@@ -0,0 +1,733 @@
//! Persisted view intent and pure visibility decisions over actual model identities.
//! Build `ViewIndex` once per accepted model revision. Preparing a view performs
//! set unions/reachability once; testing an edge never walks the model.
use crate::{
ContextualLinkPurpose, Id, Kind, Layer, OccurrenceBody, Project, RelationBody, RelationKind,
SharedBody,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExchangeFamily {
Work,
Participant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum EdgeKind {
Relation(RelationKind),
Trace,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum RelationKinds {
#[default]
All,
Only(Vec<EdgeKind>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum CategorySelection {
#[default]
All,
Any(Vec<Id>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ContextSelection {
#[default]
All,
Any(Vec<Id>),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Coverage {
#[default]
All,
InBehavior,
OutsideBehavior,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExchangeLabels {
#[default]
Name,
Items,
NameAndItems,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ViewOptions {
pub hidden_edges: Vec<Id>,
pub relation_kinds: RelationKinds,
pub work_categories: CategorySelection,
pub participant_categories: CategorySelection,
pub coverage: Coverage,
pub work_contexts: ContextSelection,
pub exchange_labels: ExchangeLabels,
}
impl ViewOptions {
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
for id in &mut self.hidden_edges {
visit(id);
}
for selection in [&mut self.work_categories, &mut self.participant_categories] {
if let CategorySelection::Any(ids) = selection {
for id in ids {
visit(id);
}
}
}
if let ContextSelection::Any(ids) = &mut self.work_contexts {
for id in ids {
visit(id);
}
}
}
/// Removing the last selected identity retains the explicit empty selector.
pub fn retain_references(&mut self, mut keep: impl FnMut(&Id) -> bool) {
self.hidden_edges.retain(&mut keep);
for selection in [&mut self.work_categories, &mut self.participant_categories] {
if let CategorySelection::Any(ids) = selection {
ids.retain(&mut keep);
}
}
if let ContextSelection::Any(ids) = &mut self.work_contexts {
ids.retain(keep);
}
}
}
/// Stable field names allow core/source adapters to locate the offending option.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionError {
pub field: &'static str,
pub reference: Option<Id>,
pub message: String,
}
pub fn exchange_family(kind: RelationKind) -> Option<ExchangeFamily> {
match kind {
RelationKind::Interaction | RelationKind::FunctionalExchange => Some(ExchangeFamily::Work),
RelationKind::BoundaryExchange | RelationKind::CommunicationMean => {
Some(ExchangeFamily::Participant)
}
_ => None,
}
}
fn category_member(p: &Project, layer: Layer, family: ExchangeFamily, id: Id) -> bool {
p.relations.get(&id).is_some_and(|r| {
p.layer_of(id) == Some(layer)
&& matches!(
(family, layer, r.kind()),
(ExchangeFamily::Work, Layer::Oa, RelationKind::Interaction)
| (
ExchangeFamily::Work,
Layer::Sa,
RelationKind::FunctionalExchange
)
| (
ExchangeFamily::Participant,
Layer::Oa,
RelationKind::CommunicationMean
)
| (
ExchangeFamily::Participant,
_,
RelationKind::BoundaryExchange
)
)
})
}
fn context_kind(kind: Kind) -> bool {
matches!(kind, Kind::Process | Kind::FunctionalChain | Kind::Scenario)
}
fn edge_layers(p: &Project, id: Id) -> Vec<Layer> {
if p.relations.contains_key(&id) {
p.layer_of(id).into_iter().collect()
} else if p.traces.get(&id).is_some_and(|trace| {
p.layer_of(trace.oa) == Some(Layer::Oa) && p.layer_of(trace.sa) == Some(Layer::Sa)
}) {
vec![Layer::Oa, Layer::Sa]
} else {
vec![]
}
}
fn error(field: &'static str, reference: Option<Id>, message: &str) -> OptionError {
OptionError {
field,
reference,
message: message.into(),
}
}
fn distinct_ids(field: &'static str, ids: &[Id], out: &mut Vec<OptionError>) {
let mut seen = BTreeSet::new();
for id in ids {
if !seen.insert(*id) {
out.push(error(
field,
Some(*id),
"Each identity must occur only once.",
));
}
}
}
pub fn validate_category(
p: &Project,
layer: Layer,
parent: Option<Id>,
family: ExchangeFamily,
exchanges: &[Id],
) -> Vec<OptionError> {
let mut out = vec![];
if parent.is_some() {
out.push(error(
"parent",
parent,
"Exchange categories belong at the layer root.",
));
}
distinct_ids("exchanges", exchanges, &mut out);
for id in exchanges {
if !category_member(p, layer, family, *id) {
out.push(error(
"exchanges",
Some(*id),
"Choose an existing exchange of this category's family and layer.",
));
}
}
out
}
pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, Some(layer), options)
}
/// Traceability views admit references from either architecture layer.
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, None, options)
}
fn validate_options_in(
p: &Project,
layer: Option<Layer>,
options: &ViewOptions,
) -> Vec<OptionError> {
let mut out = vec![];
distinct_ids("hidden_edges", &options.hidden_edges, &mut out);
for id in &options.hidden_edges {
let layers = edge_layers(p, *id);
if layers.is_empty() || layer.is_some_and(|layer| !layers.contains(&layer)) {
out.push(error(
"hidden_edges",
Some(*id),
"Choose an existing relationship or applicable trace in this view's layer.",
));
}
}
if let RelationKinds::Only(kinds) = &options.relation_kinds {
for (index, kind) in kinds.iter().enumerate() {
if kinds[..index].contains(kind) {
out.push(error(
"relation_kinds",
None,
"Each relationship-line kind must occur only once.",
));
}
}
}
for (field, selection, family) in [
(
"work_categories",
&options.work_categories,
ExchangeFamily::Work,
),
(
"participant_categories",
&options.participant_categories,
ExchangeFamily::Participant,
),
] {
if let CategorySelection::Any(ids) = selection {
distinct_ids(field, ids, &mut out);
for id in ids {
if !p.elements.get(id).is_some_and(|e| {
layer.is_none_or(|layer| e.layer() == layer)
&& matches!(e.shared(), Some(SharedBody::ExchangeCategory { family: f, .. }) if *f == family)
}) {
out.push(error(field, Some(*id), "Choose a category of this selector's family and layer."));
}
}
}
}
if let ContextSelection::Any(ids) = &options.work_contexts {
distinct_ids("work_contexts", ids, &mut out);
for id in ids {
if !p.elements.get(id).is_some_and(|e| {
layer.is_none_or(|layer| e.layer() == layer) && context_kind(e.kind())
}) {
out.push(error(
"work_contexts",
Some(*id),
"Choose a Process, Functional chain or Scenario in this view's layer.",
));
}
}
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BehaviorEvidence {
pub work: BTreeSet<Id>,
pub exchanges: BTreeSet<Id>,
}
/// Explicit inclusion/reuse graph, independent of semantic-parent descendants.
#[derive(Debug, Clone, Default)]
struct BehaviorIndex {
roots: BTreeMap<Id, Layer>,
next: BTreeMap<Id, Vec<Id>>,
facts: BTreeMap<Id, BehaviorEvidence>,
}
impl BehaviorIndex {
fn new(p: &Project) -> Self {
let mut index = Self::default();
for element in p.elements.values() {
let id = element.id;
if context_kind(element.kind()) {
index.roots.insert(id, element.layer());
}
let next = index.next.entry(id).or_default();
if let Some(steps) = element.steps() {
next.extend(
steps
.iter()
.filter(|step| {
p.elements.get(step).is_some_and(|child| {
child.kind() == Kind::Occurrence
&& child.parent == Some(id)
&& child.layer() == element.layer()
})
})
.copied(),
);
}
let Some(SharedBody::Occurrence { body, .. }) = element.shared() else {
continue;
};
match body {
OccurrenceBody::Action {
action: Some(work), ..
} => {
if p.elements
.get(work)
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
{
index.facts.entry(id).or_default().work.insert(*work);
}
}
OccurrenceBody::Exchange {
exchange: Some(exchange),
..
} => {
if let Some(r) = p.relations.get(exchange)
&& exchange_family(r.kind()) == Some(ExchangeFamily::Work)
&& [r.source(), r.target()].iter().all(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
})
{
let facts = index.facts.entry(id).or_default();
facts.exchanges.insert(*exchange);
facts.work.extend([r.source(), r.target()]);
}
}
OccurrenceBody::Fragment { branches, .. } => {
next.extend(
branches
.iter()
.filter(|branch| {
p.elements.get(branch).is_some_and(|child| {
child.parent == Some(id)
&& child.layer() == element.layer()
&& matches!(
child.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
})
})
.copied(),
);
}
OccurrenceBody::SequenceReference {
sequence: Some(sequence),
} => {
if let Some(owner) = element.parent.and_then(|id| p.elements.get(&id))
&& matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)
&& p.elements.get(sequence).is_some_and(|target| {
target.kind() == owner.kind() && target.layer() == element.layer()
})
{
next.push(*sequence);
}
}
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
..
} if p.elements.get(scenario).is_some_and(|target| {
target.kind() == Kind::Scenario && target.layer() == element.layer()
}) =>
{
next.push(*scenario);
}
_ => {}
}
}
let topology = crate::topology_context::TopologyIndex::new(p);
for relation in p.relations.values() {
if let RelationBody::ContextualLink {
owner,
source,
target,
purpose: purpose @ ContextualLinkPurpose::Exchange { exchange },
} = &relation.body
&& topology
.validate_link(*owner, source, target, purpose)
.is_ok()
{
let facts = index.facts.entry(*owner).or_default();
facts.exchanges.insert(*exchange);
if let Some(exchange) = p.relations.get(exchange) {
facts.work.extend([exchange.source(), exchange.target()]);
}
}
}
index
}
fn evidence(&self, roots: impl IntoIterator<Item = Id>) -> BehaviorEvidence {
let mut pending: Vec<_> = roots
.into_iter()
.filter(|id| self.roots.contains_key(id))
.collect();
let mut seen = BTreeSet::new();
let mut result = BehaviorEvidence::default();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
if let Some(next) = self.next.get(&id) {
pending.extend(next);
}
if let Some(facts) = self.facts.get(&id) {
result.work.extend(&facts.work);
result.exchanges.extend(&facts.exchanges);
}
}
result
}
}
pub fn behavior_evidence(p: &Project, roots: impl IntoIterator<Item = Id>) -> BehaviorEvidence {
BehaviorIndex::new(p).evidence(roots)
}
pub fn all_behavior_evidence(p: &Project) -> BehaviorEvidence {
let index = BehaviorIndex::new(p);
index.evidence(index.roots.keys().copied())
}
#[derive(Debug, Clone)]
struct EdgeFact {
kind: EdgeKind,
family: Option<ExchangeFamily>,
layers: Vec<Layer>,
}
#[derive(Debug, Clone)]
struct CategoryFact {
layer: Layer,
family: ExchangeFamily,
exchanges: BTreeSet<Id>,
}
#[derive(Debug, Clone)]
pub struct ViewIndex {
edges: BTreeMap<Id, EdgeFact>,
categories: BTreeMap<Id, CategoryFact>,
behavior: BehaviorIndex,
global: BehaviorEvidence,
}
impl ViewIndex {
pub fn new(p: &Project) -> Self {
let behavior = BehaviorIndex::new(p);
let global = behavior.evidence(behavior.roots.keys().copied());
let mut edges: BTreeMap<_, _> = p
.relations
.values()
.map(|r| {
(
r.id,
EdgeFact {
kind: EdgeKind::Relation(r.kind()),
family: exchange_family(r.kind()),
layers: edge_layers(p, r.id),
},
)
})
.collect();
edges.extend(p.traces.keys().map(|id| {
(
*id,
EdgeFact {
kind: EdgeKind::Trace,
family: None,
layers: edge_layers(p, *id),
},
)
}));
let categories = p
.elements
.values()
.filter_map(|e| {
let SharedBody::ExchangeCategory { family, exchanges } = e.shared()? else {
return None;
};
Some((
e.id,
CategoryFact {
layer: e.layer(),
family: *family,
exchanges: exchanges
.iter()
.filter(|id| category_member(p, e.layer(), *family, **id))
.copied()
.collect(),
},
))
})
.collect();
Self {
edges,
categories,
behavior,
global,
}
}
pub fn all_evidence(&self) -> &BehaviorEvidence {
&self.global
}
/// Options must pass `validate_options` before model admission. Malformed
/// selectors are nevertheless fail-closed here, useful for draft previews.
pub fn prepare(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> {
self.prepare_in(layer, options, false)
}
fn prepare_in(
&self,
layer: Layer,
options: &ViewOptions,
cross_layer: bool,
) -> PreparedFilter<'_> {
let category_union = |selection: &CategorySelection, family| match selection {
CategorySelection::All => None,
CategorySelection::Any(ids) => Some(
ids.iter()
.filter_map(|id| self.categories.get(id))
.filter(|c| (cross_layer || c.layer == layer) && c.family == family)
.flat_map(|c| c.exchanges.iter().copied())
.collect(),
),
};
let contexts = match &options.work_contexts {
ContextSelection::All => None,
ContextSelection::Any(ids) => Some(
self.behavior
.evidence(
ids.iter()
.filter(|id| {
self.behavior
.roots
.get(id)
.is_some_and(|candidate| cross_layer || *candidate == layer)
})
.copied(),
)
.exchanges,
),
};
PreparedFilter {
index: self,
layer,
cross_layer,
hidden: options.hidden_edges.iter().copied().collect(),
kinds: options.relation_kinds.clone(),
coverage: options.coverage,
work_categories: category_union(&options.work_categories, ExchangeFamily::Work),
participant_categories: category_union(
&options.participant_categories,
ExchangeFamily::Participant,
),
contexts,
}
}
/// Traceability diagrams admit edges, categories and contexts in both layers.
pub fn prepare_cross_layer(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> {
self.prepare_in(layer, options, true)
}
}
/// Projection reports why an actual edge cannot have a drawable endpoint pair.
/// Filtering does not perform anchoring, folding, layout or placement insertion.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EdgeAvailability {
Visible,
OutsideProjection,
EndpointNotShown { source: bool, target: bool },
FoldedInternal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OmissionReason {
ExplicitHide,
RelationKind,
Category(ExchangeFamily),
Coverage,
WorkContext,
OutsideProjection,
EndpointNotShown { source: bool, target: bool },
FoldedInternal,
WrongLayer,
UnknownEdge,
}
pub struct PreparedFilter<'a> {
index: &'a ViewIndex,
layer: Layer,
cross_layer: bool,
hidden: BTreeSet<Id>,
kinds: RelationKinds,
coverage: Coverage,
work_categories: Option<BTreeSet<Id>>,
participant_categories: Option<BTreeSet<Id>>,
contexts: Option<BTreeSet<Id>>,
}
impl PreparedFilter<'_> {
pub fn includes(&self, edge: Id) -> bool {
self.reasons(edge, EdgeAvailability::Visible).is_empty()
}
pub fn reasons(&self, edge: Id, availability: EdgeAvailability) -> Vec<OmissionReason> {
let mut out = vec![];
if self.hidden.contains(&edge) {
out.push(OmissionReason::ExplicitHide);
}
match availability {
EdgeAvailability::Visible => {}
EdgeAvailability::OutsideProjection => out.push(OmissionReason::OutsideProjection),
EdgeAvailability::EndpointNotShown { source, target } => {
out.push(OmissionReason::EndpointNotShown { source, target })
}
EdgeAvailability::FoldedInternal => out.push(OmissionReason::FoldedInternal),
}
let Some(fact) = self.index.edges.get(&edge) else {
out.push(OmissionReason::UnknownEdge);
return out;
};
if !fact.layers.contains(&self.layer) && !(self.cross_layer && !fact.layers.is_empty()) {
out.push(OmissionReason::WrongLayer);
}
if let RelationKinds::Only(kinds) = &self.kinds
&& !kinds.contains(&fact.kind)
{
out.push(OmissionReason::RelationKind);
}
if let Some(family) = fact.family {
let categories = match family {
ExchangeFamily::Work => &self.work_categories,
ExchangeFamily::Participant => &self.participant_categories,
};
if categories.as_ref().is_some_and(|ids| !ids.contains(&edge)) {
out.push(OmissionReason::Category(family));
}
if family == ExchangeFamily::Work {
let covered = self.index.global.exchanges.contains(&edge);
if matches!(self.coverage, Coverage::InBehavior) && !covered
|| matches!(self.coverage, Coverage::OutsideBehavior) && covered
{
out.push(OmissionReason::Coverage);
}
if self
.contexts
.as_ref()
.is_some_and(|ids| !ids.contains(&edge))
{
out.push(OmissionReason::WorkContext);
}
}
}
out
}
}
impl ExchangeLabels {
pub fn label(self, p: &Project, edge: Id) -> String {
let name = p.named(edge);
let Some(relation) = p
.relations
.get(&edge)
.filter(|r| exchange_family(r.kind()).is_some())
else {
return name.into();
};
if self == Self::Name {
return name.into();
}
let items = if relation.body.items().is_empty() {
"No items".into()
} else {
relation
.body
.items()
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", ")
};
if self == Self::Items {
items
} else {
format!("{name} · {items}")
}
}
}
#[cfg(test)]
#[path = "view_options_tests.rs"]
mod tests;
+621
View File
@@ -0,0 +1,621 @@
use super::*;
use crate::{FragmentOperator, MessageKind, RelationBody, Trace, TraceKind};
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(
Kind::Occurrence,
p.elements[&owner].layer(),
"Contribution",
Some(owner),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
steps.push(id);
}
id
}
fn exchange(id: Id) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(id),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
}
}
fn category(p: &mut Project, layer: Layer, family: ExchangeFamily, exchanges: Vec<Id>) -> Id {
let id = add(p, Kind::ExchangeCategory, layer, "Concern");
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
SharedBody::ExchangeCategory { family, exchanges };
id
}
struct Model {
p: Project,
work: [Id; 4],
means: [Id; 2],
categories: [Id; 3],
contexts: [Id; 2],
allocation: Id,
trace: Id,
}
fn model() -> Model {
let mut p = Project::empty();
let a = add(&mut p, Kind::Activity, Layer::Oa, "Request");
let b = add(&mut p, Kind::Activity, Layer::Oa, "Provide");
let c = add(&mut p, Kind::Activity, Layer::Oa, "Monitor");
let x = add(&mut p, Kind::Entity, Layer::Oa, "Requester");
let y = add(&mut p, Kind::Entity, Layer::Oa, "Provider");
let z = add(&mut p, Kind::Entity, Layer::Oa, "Observer");
let allocation = p.relate(RelationKind::Allocation, "Responsible", x, a);
p.relate(RelationKind::Allocation, "Responsible", y, b);
p.relate(RelationKind::Allocation, "Responsible", z, c);
let work = [
p.relate(RelationKind::Interaction, "Request data", a, b),
p.relate(RelationKind::Interaction, "Report", b, c),
p.relate(RelationKind::Interaction, "Feedback", c, a),
p.relate(RelationKind::Interaction, "Direct monitor", a, c),
];
let means = [
p.relate(RelationKind::CommunicationMean, "Service channel", x, y),
p.relate(RelationKind::CommunicationMean, "Monitoring channel", y, z),
];
let contexts = [
add(&mut p, Kind::Process, Layer::Oa, "Service path"),
add(&mut p, Kind::Process, Layer::Oa, "Feedback path"),
];
for edge in &work[..2] {
occurrence(&mut p, contexts[0], exchange(*edge));
}
for edge in &work[1..3] {
occurrence(&mut p, contexts[1], exchange(*edge));
}
let categories = [
category(&mut p, Layer::Oa, ExchangeFamily::Work, work[..2].to_vec()),
category(&mut p, Layer::Oa, ExchangeFamily::Work, work[1..3].to_vec()),
category(
&mut p,
Layer::Oa,
ExchangeFamily::Participant,
vec![means[0]],
),
];
let function = add(&mut p, Kind::Function, Layer::Sa, "System request");
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "request_realization".into(),
name: "Request realization".into(),
description: String::new(),
oa: a,
sa: function,
kind: TraceKind::Refinement,
baseline: None,
},
);
Model {
p,
work,
means,
categories,
contexts,
allocation,
trace,
}
}
fn accepted(filter: &PreparedFilter<'_>, ids: &[Id]) -> Vec<Id> {
ids.iter()
.filter(|id| filter.includes(**id))
.copied()
.collect()
}
#[test]
fn traceability_defaults_preserve_both_layers_and_explicit_selectors_still_apply() {
let mut m = model();
let a = add(&mut m.p, Kind::Function, Layer::Sa, "Request in system");
let b = add(&mut m.p, Kind::Function, Layer::Sa, "Provide in system");
let sa_exchange =
m.p.relate(RelationKind::FunctionalExchange, "System data", a, b);
let sa_category = category(&mut m.p, Layer::Sa, ExchangeFamily::Work, vec![sa_exchange]);
let sa_context = add(
&mut m.p,
Kind::FunctionalChain,
Layer::Sa,
"System service path",
);
occurrence(&mut m.p, sa_context, exchange(sa_exchange));
let index = ViewIndex::new(&m.p);
let defaults = ViewOptions::default();
assert!(!index.prepare(Layer::Oa, &defaults).includes(sa_exchange));
let cross_layer = index.prepare_cross_layer(Layer::Oa, &defaults);
assert!(cross_layer.includes(sa_exchange));
assert!(cross_layer.includes(m.work[0]));
assert!(cross_layer.includes(m.trace));
let selected = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0]]),
..defaults
};
let cross_layer = index.prepare_cross_layer(Layer::Oa, &selected);
assert!(cross_layer.includes(m.work[0]));
assert_eq!(
cross_layer.reasons(sa_exchange, EdgeAvailability::Visible),
vec![OmissionReason::Category(ExchangeFamily::Work)]
);
let none = ViewOptions {
relation_kinds: RelationKinds::Only(vec![]),
..ViewOptions::default()
};
assert!(
!index
.prepare_cross_layer(Layer::Oa, &none)
.includes(sa_exchange)
);
let mixed = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0], sa_category]),
work_contexts: ContextSelection::Any(vec![m.contexts[0], sa_context]),
hidden_edges: vec![sa_exchange],
..ViewOptions::default()
};
assert!(validate_options_cross_layer(&m.p, &mixed).is_empty());
assert!(!validate_options(&m.p, Layer::Oa, &mixed).is_empty());
let cross_layer = index.prepare_cross_layer(Layer::Oa, &mixed);
assert!(cross_layer.includes(m.work[0]));
assert_eq!(
cross_layer.reasons(sa_exchange, EdgeAvailability::Visible),
vec![OmissionReason::ExplicitHide]
);
}
#[test]
fn separate_category_families_obey_all_any_empty_and_union_truth_table() {
let m = model();
let index = ViewIndex::new(&m.p);
let work_cases = [
(CategorySelection::All, m.work.to_vec()),
(CategorySelection::Any(vec![]), vec![]),
(
CategorySelection::Any(vec![m.categories[0]]),
m.work[..2].to_vec(),
),
(
CategorySelection::Any(m.categories[..2].to_vec()),
m.work[..3].to_vec(),
),
];
let participant_cases = [
(CategorySelection::All, m.means.to_vec()),
(CategorySelection::Any(vec![]), vec![]),
(
CategorySelection::Any(vec![m.categories[2]]),
vec![m.means[0]],
),
];
for (work_categories, expected_work) in &work_cases {
for (participant_categories, expected_means) in &participant_cases {
let options = ViewOptions {
work_categories: work_categories.clone(),
participant_categories: participant_categories.clone(),
..ViewOptions::default()
};
assert!(validate_options(&m.p, Layer::Oa, &options).is_empty());
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(&accepted(&filter, &m.work), expected_work);
assert_eq!(&accepted(&filter, &m.means), expected_means);
assert!(filter.includes(m.allocation));
assert!(filter.includes(m.trace));
}
}
}
#[test]
fn global_behavior_coverage_and_chosen_context_are_independent_intersecting_predicates() {
let m = model();
let index = ViewIndex::new(&m.p);
let coverage_cases = [
(Coverage::All, m.work.to_vec()),
(Coverage::InBehavior, m.work[..3].to_vec()),
(Coverage::OutsideBehavior, vec![m.work[3]]),
];
let context_cases = [
(ContextSelection::All, m.work.to_vec()),
(ContextSelection::Any(vec![]), vec![]),
(
ContextSelection::Any(vec![m.contexts[0]]),
m.work[..2].to_vec(),
),
(
ContextSelection::Any(vec![m.contexts[1]]),
m.work[1..3].to_vec(),
),
(
ContextSelection::Any(m.contexts.to_vec()),
m.work[..3].to_vec(),
),
];
for (coverage, global) in &coverage_cases {
for (work_contexts, chosen) in &context_cases {
let expected: Vec<_> = global
.iter()
.filter(|id| chosen.contains(id))
.copied()
.collect();
let options = ViewOptions {
coverage: *coverage,
work_contexts: work_contexts.clone(),
..ViewOptions::default()
};
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(accepted(&filter, &m.work), expected);
assert_eq!(accepted(&filter, &m.means), m.means);
assert!(filter.includes(m.allocation));
}
}
}
#[test]
fn no_lines_and_explicit_trace_selection_include_actual_trace_identities() {
let m = model();
let index = ViewIndex::new(&m.p);
let all: Vec<_> = m
.work
.into_iter()
.chain(m.means)
.chain([m.allocation, m.trace])
.collect();
for (relation_kinds, expected) in [
(RelationKinds::All, all.clone()),
(RelationKinds::Only(vec![]), vec![]),
(RelationKinds::Only(vec![EdgeKind::Trace]), vec![m.trace]),
(
RelationKinds::Only(vec![
EdgeKind::Relation(RelationKind::Interaction),
EdgeKind::Trace,
]),
m.work.into_iter().chain([m.trace]).collect(),
),
] {
let options = ViewOptions {
relation_kinds,
..ViewOptions::default()
};
assert_eq!(
accepted(&index.prepare(Layer::Oa, &options), &all),
expected
);
}
}
#[test]
fn hidden_identity_survives_label_and_category_changes_without_mutating_the_model() {
let mut m = model();
let item = add(&mut m.p, Kind::ExchangeItem, Layer::Oa, "Request payload");
if let RelationBody::Interaction { items, .. } =
&mut m.p.relations.get_mut(&m.work[0]).unwrap().body
{
items.push(item);
}
let before = m.p.clone();
let index = ViewIndex::new(&m.p);
let mut options = ViewOptions {
hidden_edges: vec![m.work[0]],
..ViewOptions::default()
};
for labels in [
ExchangeLabels::Name,
ExchangeLabels::Items,
ExchangeLabels::NameAndItems,
] {
options.exchange_labels = labels;
for categories in [
CategorySelection::All,
CategorySelection::Any(vec![m.categories[0]]),
CategorySelection::Any(vec![]),
] {
options.work_categories = categories;
let filter = index.prepare(Layer::Oa, &options);
assert!(!filter.includes(m.work[0]));
assert!(
filter
.reasons(m.work[0], EdgeAvailability::Visible)
.contains(&OmissionReason::ExplicitHide)
);
}
}
assert_eq!(
ExchangeLabels::Items.label(&m.p, m.work[0]),
"Request payload"
);
assert_eq!(
ExchangeLabels::NameAndItems.label(&m.p, m.work[0]),
"Request data · Request payload"
);
assert_eq!(ExchangeLabels::Items.label(&m.p, m.work[1]), "No items");
assert_eq!(
ExchangeLabels::Items.label(&m.p, m.trace),
"Request realization"
);
assert_eq!(m.p, before);
assert_eq!(options.hidden_edges, vec![m.work[0]]);
}
#[test]
fn omission_reasons_distinguish_missing_placements_folded_edges_and_explicit_hides() {
let m = model();
let index = ViewIndex::new(&m.p);
let options = ViewOptions::default();
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(
filter.reasons(
m.work[0],
EdgeAvailability::EndpointNotShown {
source: false,
target: true
}
),
vec![OmissionReason::EndpointNotShown {
source: false,
target: true
}]
);
assert_eq!(
filter.reasons(m.work[0], EdgeAvailability::FoldedInternal),
vec![OmissionReason::FoldedInternal]
);
assert_eq!(
filter.reasons(m.work[0], EdgeAvailability::OutsideProjection),
vec![OmissionReason::OutsideProjection]
);
let options = ViewOptions {
hidden_edges: vec![m.work[0]],
work_categories: CategorySelection::Any(vec![]),
..options
};
let reasons = index.prepare(Layer::Oa, &options).reasons(
m.work[0],
EdgeAvailability::EndpointNotShown {
source: true,
target: true,
},
);
assert!(reasons.contains(&OmissionReason::ExplicitHide));
assert!(reasons.contains(&OmissionReason::Category(ExchangeFamily::Work)));
assert!(reasons.contains(&OmissionReason::EndpointNotShown {
source: true,
target: true
}));
}
#[test]
fn wrong_family_layer_and_unknown_references_reject_and_draft_filters_fail_closed() {
let m = model();
let index = ViewIndex::new(&m.p);
for wrong in [m.categories[2], m.work[0], Id::new_v4()] {
let options = ViewOptions {
work_categories: CategorySelection::Any(vec![wrong]),
..ViewOptions::default()
};
assert!(
validate_options(&m.p, Layer::Oa, &options)
.iter()
.any(|e| e.field == "work_categories")
);
assert!(accepted(&index.prepare(Layer::Oa, &options), &m.work).is_empty());
}
let options = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0]]),
work_contexts: ContextSelection::Any(vec![m.contexts[0]]),
hidden_edges: vec![m.work[0]],
..ViewOptions::default()
};
let errors = validate_options(&m.p, Layer::Sa, &options);
for field in ["work_categories", "work_contexts", "hidden_edges"] {
assert!(errors.iter().any(|e| e.field == field));
}
assert!(validate_category(&m.p, Layer::Oa, None, ExchangeFamily::Work, &m.work).is_empty());
assert!(
validate_category(&m.p, Layer::Oa, None, ExchangeFamily::Participant, &m.means).is_empty()
);
for (family, members) in [
(ExchangeFamily::Work, m.means.to_vec()),
(ExchangeFamily::Participant, m.work.to_vec()),
(ExchangeFamily::Work, vec![m.work[0], m.work[0]]),
] {
assert!(!validate_category(&m.p, Layer::Oa, None, family, &members).is_empty());
}
assert!(
!validate_category(
&m.p,
Layer::Oa,
Some(m.contexts[0]),
ExchangeFamily::Work,
&[]
)
.is_empty()
);
}
#[test]
fn persisted_empty_selectors_and_lifecycle_never_widen_to_all() {
let m = model();
let mut options = ViewOptions {
hidden_edges: vec![m.work[0]],
relation_kinds: RelationKinds::Only(vec![]),
work_categories: CategorySelection::Any(vec![m.categories[0]]),
participant_categories: CategorySelection::Any(vec![m.categories[2]]),
work_contexts: ContextSelection::Any(vec![m.contexts[0]]),
coverage: Coverage::OutsideBehavior,
exchange_labels: ExchangeLabels::Items,
};
let replacements: BTreeMap<_, _> = [m.work[0], m.categories[0], m.categories[2], m.contexts[0]]
.into_iter()
.map(|id| (id, Id::new_v4()))
.collect();
options.visit_references(|id| *id = replacements[id]);
assert_eq!(options.hidden_edges, vec![replacements[&m.work[0]]]);
assert_eq!(
options.work_categories,
CategorySelection::Any(vec![replacements[&m.categories[0]]])
);
assert_eq!(
options.participant_categories,
CategorySelection::Any(vec![replacements[&m.categories[2]]])
);
assert_eq!(
options.work_contexts,
ContextSelection::Any(vec![replacements[&m.contexts[0]]])
);
options.retain_references(|_| false);
assert_eq!(options.work_categories, CategorySelection::Any(vec![]));
assert_eq!(
options.participant_categories,
CategorySelection::Any(vec![])
);
assert_eq!(options.work_contexts, ContextSelection::Any(vec![]));
assert_eq!(options.relation_kinds, RelationKinds::Only(vec![]));
assert_eq!(options.coverage, Coverage::OutsideBehavior);
assert_eq!(options.exchange_labels, ExchangeLabels::Items);
assert_eq!(
serde_json::from_value::<ViewOptions>(serde_json::to_value(&options).unwrap()).unwrap(),
options
);
assert_ne!(options, ViewOptions::default());
assert_eq!(
serde_json::from_str::<ViewOptions>("{}").unwrap(),
ViewOptions::default()
);
assert!(serde_json::from_str::<ViewOptions>(r#"{"future_filter":true}"#).is_err());
assert!(serde_json::from_str::<ViewOptions>(r#"{"coverage":"Maybe"}"#).is_err());
let duplicate = ViewOptions {
relation_kinds: RelationKinds::Only(vec![EdgeKind::Trace, EdgeKind::Trace]),
hidden_edges: vec![m.trace, m.trace],
..ViewOptions::default()
};
let errors = validate_options(&m.p, Layer::Oa, &duplicate);
assert!(errors.iter().any(|e| e.field == "relation_kinds"));
assert!(errors.iter().any(|e| e.field == "hidden_edges"));
}
#[test]
fn reusable_sequences_and_nested_scenarios_use_listed_contributions_in_both_layers() {
for (layer, work_kind, sequence_kind, exchange_kind) in [
(
Layer::Oa,
Kind::Activity,
Kind::Process,
RelationKind::Interaction,
),
(
Layer::Sa,
Kind::Function,
Kind::FunctionalChain,
RelationKind::FunctionalExchange,
),
] {
let mut p = Project::empty();
let a = add(&mut p, work_kind, layer, "Request");
let b = add(&mut p, work_kind, layer, "Provide");
let used = p.relate(exchange_kind, "Included", a, b);
let unlisted = p.relate(exchange_kind, "Unlisted", b, a);
let inner = add(&mut p, sequence_kind, layer, "Reusable sequence");
let outer = add(&mut p, sequence_kind, layer, "Chosen sequence");
occurrence(&mut p, inner, exchange(used));
occurrence(
&mut p,
outer,
OccurrenceBody::SequenceReference {
sequence: Some(inner),
},
);
let unused = occurrence(&mut p, inner, exchange(unlisted));
p.elements
.get_mut(&inner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != unused);
assert_eq!(
behavior_evidence(&p, [outer]).exchanges,
BTreeSet::from([used])
);
assert!(behavior_evidence(&p, [unused]).exchanges.is_empty());
let scenario = add(&mut p, Kind::Scenario, layer, "Conversation");
let fragment = occurrence(
&mut p,
scenario,
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = occurrence(
&mut p,
fragment,
OccurrenceBody::Branch {
guard: "requested".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
occurrence(&mut p, branch, exchange(unlisted));
let reuse = add(&mut p, Kind::Scenario, layer, "Chosen conversation");
occurrence(
&mut p,
reuse,
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![],
},
);
assert_eq!(
behavior_evidence(&p, [reuse]).exchanges,
BTreeSet::from([unlisted])
);
assert_eq!(
all_behavior_evidence(&p).exchanges,
BTreeSet::from([used, unlisted])
);
assert_eq!(behavior_evidence(&p, [outer]).work, BTreeSet::from([a, b]));
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.clear();
assert!(behavior_evidence(&p, [reuse]).exchanges.is_empty());
// A malformed reuse cycle must terminate without inventing an occurrence.
occurrence(
&mut p,
scenario,
OccurrenceBody::ScenarioUse {
scenario: Some(reuse),
bindings: vec![],
},
);
assert!(behavior_evidence(&p, [reuse]).exchanges.is_empty());
}
}
@@ -0,0 +1,249 @@
//! Independent oracle for the OA communication / SA boundary direction seam.
use oa_core::{
transitions::*,
validation::{self, MessageEndpointResolution as Resolution},
*,
};
fn line(p: &mut Project, scenario: Id, owner: Id) -> Id {
let id = p
.add_element(Kind::Lifeline, Layer::Sa, "Line", Some(scenario))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(owner),
};
id
}
fn message(
exchange: Id,
sender: Id,
receiver: Id,
kind: MessageKind,
reply: Option<Id>,
) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: Some(sender),
receiver: Some(receiver),
message_kind: kind,
reply_to: reply,
values: vec![],
}
}
fn occurrence(p: &mut Project, scenario: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(Kind::Occurrence, Layer::Sa, "Message", Some(scenario))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap();
if !steps.contains(&id) {
steps.push(id);
}
id
}
fn boundary_fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Entity, Layer::Oa, "Team", None)
.unwrap();
let ob = p
.add_element(Kind::Actor, Layer::Oa, "Control", None)
.unwrap();
let sa = p
.add_element(Kind::System, Layer::Sa, "Radio", None)
.unwrap();
let sb = p
.add_element(Kind::SystemActor, Layer::Sa, "Radio control", None)
.unwrap();
let mean = p.relate(RelationKind::CommunicationMean, "Channel", oa, ob);
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
request.mappings = vec![
TransitionMapping {
source: oa,
target: Some(sa),
target_kind: None,
},
TransitionMapping {
source: ob,
target: Some(sb),
target_kind: None,
},
];
let port = |alias: &str| CommunicationPortChoice::Create {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
};
request
.communication_endpoints
.push(CommunicationEndpointDecision {
source: mean,
source_port: port("radio_a"),
target_port: port("radio_b"),
});
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
p = apply_transition(&p, &plan).unwrap();
let boundary = p.traces.values().find(|t| t.oa == mean).unwrap().sa;
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Reverse request", None)
.unwrap();
if let Some(SharedBody::Scenario { viewpoint, .. }) =
p.elements.get_mut(&scenario).unwrap().shared_mut()
{
*viewpoint = ScenarioViewpoint::BoundaryInterface;
}
let a = line(&mut p, scenario, sa);
let b = line(&mut p, scenario, sb);
(p, boundary, scenario, a, b)
}
#[test]
fn mapped_unoriented_boundary_resolves_reverse_call_and_reply_from_actual_binding() {
let (mut p, boundary, scenario, a, b) = boundary_fixture();
let (source, target) = p.relations[&boundary].body.endpoints();
let call = message(boundary, b, a, MessageKind::Call, None);
assert_eq!(
validation::resolve_message_endpoints(&p, &call),
Resolution::Resolved {
source: target,
target: source
}
);
let call = occurrence(&mut p, scenario, call);
let reply = message(boundary, a, b, MessageKind::Reply, Some(call));
assert_eq!(
validation::resolve_message_endpoints(&p, &reply),
Resolution::Resolved { source, target }
);
occurrence(&mut p, scenario, reply);
let errors: Vec<_> = validation::validate(&p)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn unoriented_flow_boundary_honors_ordinary_direction_but_preserves_correlated_reply_exception() {
let (mut p, boundary, scenario, a, b) = boundary_fixture();
let (source, target) = p.relations[&boundary].body.endpoints();
for (port, orientation) in [
(source, ComponentPortOrientation::Out),
(target, ComponentPortOrientation::In),
] {
if let Some(SharedBody::Port { contract, .. }) =
p.elements.get_mut(&port).unwrap().shared_mut()
{
*contract = PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
};
}
}
let reverse = message(boundary, b, a, MessageKind::Call, None);
assert_eq!(
validation::resolve_message_endpoints(&p, &reverse),
Resolution::Conflict
);
let forward = message(boundary, a, b, MessageKind::Call, None);
assert_eq!(
validation::resolve_message_endpoints(&p, &forward),
Resolution::Resolved { source, target }
);
let call = occurrence(&mut p, scenario, forward);
let reply = message(boundary, b, a, MessageKind::Reply, Some(call));
assert_eq!(
validation::resolve_message_endpoints(&p, &reply),
Resolution::Resolved {
source: target,
target: source
}
);
occurrence(&mut p, scenario, reply);
let errors: Vec<_> = validation::validate(&p)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn reverse_boundary_scenario_transition_keeps_call_orientation_and_reply_identity() {
let (mut p, boundary, scenario, a, b) = boundary_fixture();
let mean = p.traces.values().find(|t| t.sa == boundary).unwrap().oa;
let call = message(boundary, b, a, MessageKind::Call, None);
let call = occurrence(&mut p, scenario, call);
let reply = message(boundary, a, b, MessageKind::Reply, Some(call));
let reply = occurrence(&mut p, scenario, reply);
let owner = |port| p.elements[&port].parent.unwrap();
let mut request = TransitionRequest::new(TransitionDirection::SaToOa, vec![scenario]);
request.scope = DependencyScope::Subtree;
request.mappings = [
(boundary, mean),
(
owner(p.relations[&boundary].source()),
p.relations[&mean].source(),
),
(
owner(p.relations[&boundary].target()),
p.relations[&mean].target(),
),
]
.into_iter()
.map(|(source, target)| TransitionMapping {
source,
target: Some(target),
target_kind: None,
})
.collect();
// The explicit participant counterparts intentionally use different names.
// Review those prose differences without changing the endpoint decision.
request.resolutions = request
.mappings
.iter()
.map(|mapping| FieldResolution {
source: mapping.source,
field: TransitionField::Name,
decision: FieldDecision::KeepTarget,
})
.collect();
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_transition(&p, &plan).unwrap();
let mapped = |source| q.traces.values().find(|t| t.sa == source).unwrap().oa;
let body = |id| match q.elements[&id].shared().unwrap() {
SharedBody::Occurrence { body, .. } => body,
_ => panic!("expected occurrence"),
};
let mapped_call = mapped(call);
let mapped_reply = mapped(reply);
assert_eq!(
validation::resolve_message_endpoints(&q, body(mapped_call)),
Resolution::Resolved {
source: q.relations[&mean].target(),
target: q.relations[&mean].source(),
}
);
assert_eq!(
validation::resolve_message_endpoints(&q, body(mapped_reply)),
Resolution::Resolved {
source: q.relations[&mean].source(),
target: q.relations[&mean].target(),
}
);
assert!(
matches!(body(mapped_reply), OccurrenceBody::Exchange { reply_to: Some(id), .. } if *id == mapped_call)
);
assert_eq!(q.elements[&scenario], p.elements[&scenario]);
assert_eq!(q.elements[&call], p.elements[&call]);
assert_eq!(q.elements[&reply], p.elements[&reply]);
}
+523
View File
@@ -0,0 +1,523 @@
use oa_core::{
transitions::*,
validation::{self, MessageEndpointResolution as Resolution, Severity},
*,
};
use std::collections::BTreeMap;
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let a = add(&mut p, Kind::Entity, Layer::Oa, "Rescue team");
let b = add(&mut p, Kind::Actor, Layer::Oa, "Control centre");
let mean = p.relate(RelationKind::CommunicationMean, "Radio", a, b);
(p, a, b, mean)
}
fn errors(p: &Project) -> Vec<validation::Issue> {
validation::validate(p)
.into_iter()
.filter(|i| i.severity == Severity::Error)
.collect()
}
fn valid(p: &Project) {
assert!(errors(p).is_empty(), "{:?}", errors(p));
}
fn oriented(p: &mut Project, id: Id, value: bool) {
if let RelationBody::CommunicationMean { oriented, .. } =
&mut p.relations.get_mut(&id).unwrap().body
{
*oriented = value;
}
}
fn items(p: &mut Project, id: Id, values: Vec<Id>) {
match &mut p.relations.get_mut(&id).unwrap().body {
RelationBody::CommunicationMean { items, .. } | RelationBody::Interaction { items, .. } => {
*items = values
}
_ => panic!(),
}
}
fn has(p: &Project, id: Id, code: &str, severity: Severity) -> bool {
validation::validate(p)
.iter()
.any(|i| i.element == Some(id) && i.code == code && i.severity == severity)
}
fn line(p: &mut Project, scenario: Id, represented: Id) -> Id {
if let Some(SharedBody::Scenario { viewpoint, .. }) =
p.elements.get_mut(&scenario).unwrap().shared_mut()
{
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
}
let id = p
.add_element(
Kind::Lifeline,
Layer::Oa,
&format!("line_{represented}"),
Some(scenario),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
id
}
fn message(
mean: Id,
sender: Id,
receiver: Id,
kind: MessageKind,
reply_to: Option<Id>,
) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(mean),
sender: Some(sender),
receiver: Some(receiver),
message_kind: kind,
reply_to,
values: vec![],
}
}
fn occurrence(p: &mut Project, scenario: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(Kind::Occurrence, Layer::Oa, "Message", Some(scenario))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap();
if !steps.contains(&id) {
steps.push(id);
}
id
}
#[test]
fn communication_means_are_distinct_oa_participant_relations_and_strict_source6_records() {
let (mut p, a, b, mean) = fixture();
valid(&p);
assert!(matches!(
p.relations[&mean].body,
RelationBody::CommunicationMean {
oriented: false,
..
}
));
p.relate(RelationKind::CommunicationMean, "Telephone", a, b);
valid(&p);
let text = language::format(&p);
assert!(text.contains("communication_mean"));
assert!(text.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
assert_eq!(language::parse(&text).unwrap(), p);
let bytes = serde_json::to_vec(&p).unwrap();
assert_eq!(persistence::decode(&bytes).unwrap(), p);
for version in 1..=5 {
let mut old = serde_json::to_value(&p).unwrap();
old["schema_version"] = version.into();
assert!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err(),
"schema {version}"
);
assert!(
language::parse(&text.replacen(
&format!("arcadia {SCHEMA_VERSION};"),
&format!("arcadia {version};"),
1
))
.is_err(),
"source {version}"
);
}
let mut bad = serde_json::to_value(&p).unwrap();
bad["relations"][mean.to_string()]["body"]["CommunicationMean"]["typo"] = true.into();
assert!(persistence::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
for endpoint in [
a,
add(&mut p, Kind::Role, Layer::Oa, "Role"),
add(&mut p, Kind::System, Layer::Sa, "System"),
] {
let mut q = p.clone();
q.relations
.get_mut(&mean)
.unwrap()
.body
.set_endpoints(a, endpoint);
assert!(!errors(&q).is_empty());
}
}
#[test]
fn communication_schema5_upgrade_preserves_legacy_data_and_exact_source_trivia() {
let p = Project::demo();
let mut json = serde_json::to_value(&p).unwrap();
json["schema_version"] = 5.into();
for d in json["diagrams"].as_object_mut().unwrap().values_mut() {
d.as_object_mut().unwrap().remove("view_options");
}
let result = persistence::decode_with_report(&serde_json::to_vec(&json).unwrap()).unwrap();
assert_eq!(result.project, p);
assert_eq!(result.migration.unwrap().from_schema, 5);
let mut without_views = p.clone();
without_views.diagrams.clear();
let old = language::format(&without_views).replacen(
&format!("arcadia {SCHEMA_VERSION};"),
"arcadia // old contract\n5;",
1,
);
let bundle = source_project::SourceBundle {
entry: "entry.arcadia".into(),
files: BTreeMap::from([("entry.arcadia".into(), old.clone())]),
};
let compiled =
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default()).unwrap();
assert_eq!(
compiled.bundle.files["entry.arcadia"],
old.replacen("\n5;", &format!("\n{SCHEMA_VERSION};"), 1)
);
assert_eq!(compiled.decoded.project, without_views);
}
#[test]
fn communication_allocation_checks_whole_partial_pair_and_does_not_guess_roles() {
let (mut p, a, b, mean) = fixture();
let x = add(&mut p, Kind::Activity, Layer::Oa, "Send");
let y = add(&mut p, Kind::Activity, Layer::Oa, "Receive");
let work = p.relate(RelationKind::Interaction, "Report", x, y);
let allocation = p.relate(
RelationKind::ExchangeAllocation,
"Radio carries report",
mean,
work,
);
let role = add(&mut p, Kind::Role, Layer::Oa, "Communicator");
p.relate(RelationKind::RoleRealization, "Role", a, role);
p.relate(RelationKind::RoleAssignment, "Responsibility", role, x);
valid(&p);
assert!(has(
&p,
allocation,
"incomplete_exchange_allocation",
Severity::Warning
));
p.relate(RelationKind::Allocation, "Actual receiver", a, y);
valid(&p); // reverse candidate survives
oriented(&mut p, mean, true);
assert!(has(
&p,
allocation,
"exchange_allocation_performer",
Severity::Error
));
oriented(&mut p, mean, false);
p.relate(RelationKind::Allocation, "Actual sender", b, x);
valid(&p);
let mut q = p.clone();
q.relations.get_mut(&mean).unwrap().body.set_endpoints(b, a);
valid(&q);
let third = add(&mut q, Kind::Entity, Layer::Oa, "Third");
q.relations
.get_mut(&mean)
.unwrap()
.body
.set_endpoints(a, third);
assert!(has(
&q,
allocation,
"exchange_allocation_performer",
Severity::Error
));
}
#[test]
fn communication_allocation_items_use_specialization_and_empty_is_not_wildcard() {
let (mut p, a, b, mean) = fixture();
let x = add(&mut p, Kind::Activity, Layer::Oa, "Send");
let y = add(&mut p, Kind::Activity, Layer::Oa, "Receive");
p.relate(RelationKind::Allocation, "Send allocation", a, x);
p.relate(RelationKind::Allocation, "Receive allocation", b, y);
let work = p.relate(RelationKind::Interaction, "Report", x, y);
let allocation = p.relate(RelationKind::ExchangeAllocation, "Carried", mean, work);
let base = add(&mut p, Kind::ExchangeItem, Layer::Oa, "Report item");
let specialized = add(&mut p, Kind::ExchangeItem, Layer::Oa, "Distress item");
if let Some(SharedBody::ExchangeItem { supertype, .. }) =
p.elements.get_mut(&specialized).unwrap().shared_mut()
{
*supertype = Some(base);
}
items(&mut p, work, vec![specialized]);
assert!(has(
&p,
allocation,
"exchange_allocation_items",
Severity::Error
));
items(&mut p, mean, vec![base]);
valid(&p);
items(&mut p, work, vec![base]);
items(&mut p, mean, vec![specialized]);
assert!(has(
&p,
allocation,
"exchange_allocation_items",
Severity::Error
));
}
#[test]
fn communication_scenario_reverse_call_and_reply_follow_actual_direction() {
let (mut p, a, b, mean) = fixture();
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Radio conversation");
let la = line(&mut p, scenario, a);
let lb = line(&mut p, scenario, b);
let call_body = message(mean, lb, la, MessageKind::Call, None);
let call = occurrence(&mut p, scenario, call_body.clone());
let reply_body = message(mean, la, lb, MessageKind::Reply, Some(call));
occurrence(&mut p, scenario, reply_body.clone());
assert_eq!(
validation::resolve_message_endpoints(&p, &call_body),
Resolution::Resolved {
source: b,
target: a
}
);
assert_eq!(
validation::resolve_message_endpoints(&p, &reply_body),
Resolution::Resolved {
source: a,
target: b
}
);
valid(&p);
let mut edited = p.relations[&mean].clone();
if let RelationBody::CommunicationMean { oriented, .. } = &mut edited.body {
*oriented = true;
}
assert!(prepare_command(&p, &Command::EditRelation(mean, edited)).is_err());
valid(&p);
// The established operation-response exception also allows same-means Reply on oriented means.
if let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get_mut(&call).unwrap().shared_mut()
{
*body = message(mean, la, lb, MessageKind::Call, None);
}
oriented(&mut p, mean, true);
assert_eq!(
validation::resolve_message_endpoints(
&p,
&message(mean, lb, la, MessageKind::Reply, Some(call))
),
Resolution::Resolved {
source: b,
target: a
}
);
}
#[test]
fn communication_scenario_ambiguous_and_incomplete_bindings_never_guess() {
let (mut p, a, b, mean) = fixture();
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Roles");
let role = add(&mut p, Kind::Role, Layer::Oa, "Shared role");
p.relate(RelationKind::RoleRealization, "A role", a, role);
p.relate(RelationKind::RoleRealization, "B role", b, role);
let one = line(&mut p, scenario, role);
let two = line(&mut p, scenario, role);
let mut body = message(mean, one, two, MessageKind::Asynchronous, None);
assert_eq!(
validation::resolve_message_endpoints(&p, &body),
Resolution::Ambiguous
);
assert_eq!(validation::message_endpoints(&p, &body), None);
if let OccurrenceBody::Exchange { receiver, .. } = &mut body {
*receiver = None;
}
assert_eq!(
validation::resolve_message_endpoints(&p, &body),
Resolution::Incomplete
);
let third = add(&mut p, Kind::Entity, Layer::Oa, "Other");
let other = line(&mut p, scenario, third);
if let OccurrenceBody::Exchange { sender, .. } = &mut body {
*sender = Some(other);
}
assert_eq!(
validation::resolve_message_endpoints(&p, &body),
Resolution::Conflict
);
}
fn transition_fixture() -> (Project, Id, Id, Id, TransitionRequest) {
let (mut p, a, b, mean) = fixture();
let sa = add(&mut p, Kind::System, Layer::Sa, "System");
let sb = add(&mut p, Kind::SystemActor, Layer::Sa, "External");
let mut r = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
r.mappings = vec![
TransitionMapping {
source: a,
target: Some(sa),
target_kind: None,
},
TransitionMapping {
source: b,
target: Some(sb),
target_kind: None,
},
];
let port = |name: &str| CommunicationPortChoice::Create {
id: Id::new_v4(),
alias: name.into(),
name: name.into(),
description: "Explicit technical choice".into(),
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
};
r.communication_endpoints
.push(CommunicationEndpointDecision {
source: mean,
source_port: port("radio_source"),
target_port: port("radio_target"),
});
(p, mean, sa, sb, r)
}
#[test]
fn communication_transition_requires_explicit_ports_and_preserves_participant_mapping() {
let (p, mean, sa, sb, r) = transition_fixture();
let mut incomplete = r.clone();
incomplete.communication_endpoints.clear();
assert!(
!plan_transition(&p, &incomplete)
.unwrap()
.conflicts
.is_empty()
);
let plan = plan_transition(&p, &r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_transition(&p, &plan).unwrap();
valid(&q);
let trace = q.traces.values().find(|t| t.oa == mean).unwrap();
assert_eq!(trace.kind, TraceKind::Exchange);
let boundary = &q.relations[&trace.sa];
assert_eq!(boundary.kind(), RelationKind::BoundaryExchange);
assert_eq!(q.elements[&boundary.source()].parent, Some(sa));
assert_eq!(q.elements[&boundary.target()].parent, Some(sb));
assert_eq!(q.relations[&mean], p.relations[&mean]);
assert_eq!(q.elements.len(), p.elements.len() + 2);
let mut collision = r.clone();
collision.mappings[1].target = Some(sa);
assert!(
!plan_transition(&p, &collision)
.unwrap()
.conflicts
.is_empty()
);
let mut duplicate = r.clone();
duplicate
.communication_endpoints
.push(r.communication_endpoints[0].clone());
assert!(
!plan_transition(&p, &duplicate)
.unwrap()
.conflicts
.is_empty()
);
}
#[test]
fn communication_reverse_transition_projects_owners_without_oa_ports() {
let (p, mean, sa, sb, r) = transition_fixture();
let q = apply_transition(&p, &plan_transition(&p, &r).unwrap()).unwrap();
let boundary = q.traces.values().find(|t| t.oa == mean).unwrap().sa;
let mut standalone = q.clone();
standalone.traces.clear();
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![boundary]);
reverse.mappings = vec![
TransitionMapping {
source: sa,
target: Some(p.relations[&mean].source()),
target_kind: None,
},
TransitionMapping {
source: sb,
target: Some(p.relations[&mean].target()),
target_kind: None,
},
];
let plan = plan_transition(&standalone, &reverse).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = apply_transition(&standalone, &plan).unwrap();
assert_eq!(result.elements, standalone.elements);
let counterpart = &result.relations[&result
.traces
.values()
.find(|t| t.sa == boundary)
.unwrap()
.oa];
assert_eq!(counterpart.kind(), RelationKind::CommunicationMean);
assert_eq!(
counterpart.body.endpoints(),
p.relations[&mean].body.endpoints()
);
}
#[test]
fn communication_semantic_endpoint_take_needs_scoped_decision_and_exact_history() {
let (p, mean, _, _, r) = transition_fixture();
let q = apply_transition(&p, &plan_transition(&p, &r).unwrap()).unwrap();
let trace = q.traces.values().find(|t| t.oa == mean).unwrap();
let boundary = trace.sa;
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
transition.mappings = r.mappings.clone();
transition.mappings.push(TransitionMapping {
source: mean,
target: Some(boundary),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.references = r
.mappings
.iter()
.map(|m| (m.source, m.target.unwrap()))
.collect();
request.reviews.push(SemanticReviewRequest {
source: mean,
resolutions: vec![FacetResolution {
facet: FacetSelector::Relation(RelationFacet::Source),
decision: SemanticDecision::TakeSource,
}],
reset: vec![],
});
assert!(
!plan_semantic_transition(&q, request.clone())
.unwrap()
.conflicts
.is_empty()
);
request
.transition
.communication_endpoints
.push(CommunicationEndpointDecision {
source: mean,
source_port: CommunicationPortChoice::Reuse {
port: q.relations[&boundary].source(),
},
target_port: CommunicationPortChoice::Reuse {
port: q.relations[&boundary].target(),
},
});
let plan = plan_semantic_transition(&q, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = apply_semantic_transition(&q, &plan).unwrap();
let baseline = result.traces[&trace.id]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
assert!(baseline.facets.iter().any(|f|matches!(f,FacetReview::Relation(RelationReview::Source(Reviewed{oa:Observed::Value(a),sa:Observed::Value(b)})) if *a==p.relations[&mean].source()&&*b==q.relations[&boundary].source())));
}
@@ -0,0 +1,585 @@
use oa_core::{
language,
source_project::*,
validation::{self, Severity},
*,
};
use std::collections::{BTreeMap, BTreeSet};
const CODES: [&str; 5] = [
"non_leaf_work_exchange",
"work_without_behavior_context",
"exchange_without_behavior_context",
"mission_without_capability",
"capability_without_behavior_context",
];
type Located = (String, Id, Option<String>);
fn coverage(p: &Project) -> BTreeSet<Located> {
let issues = validation::validate(p);
assert!(
!issues.iter().any(|i| i.severity == Severity::Error),
"{issues:?}"
);
issues
.into_iter()
.filter(|i| CODES.contains(&i.code.as_str()))
.map(|i| {
assert_eq!(i.severity, Severity::Warning);
(i.code, i.element.unwrap(), i.field)
})
.collect()
}
fn located(code: &str, id: Id, field: &str) -> Located {
(code.into(), id, (!field.is_empty()).then(|| field.into()))
}
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(
Kind::Occurrence,
p.elements[&owner].layer(),
"Contribution",
Some(owner),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
steps.push(id);
}
id
}
fn exchange(id: Id) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(id),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
}
}
fn action(id: Id) -> OccurrenceBody {
OccurrenceBody::Action {
action: Some(id),
lifeline: None,
}
}
fn line(p: &mut Project, scenario: Id, work: Id) -> Id {
let id = p
.add_element(
Kind::Lifeline,
p.elements[&scenario].layer(),
"Work line",
Some(scenario),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(work),
};
id
}
fn set_context(p: &mut Project, scenario: Id, capability: Id) {
if let Some(SharedBody::Scenario { context, .. }) =
p.elements.get_mut(&scenario).unwrap().shared_mut()
{
*context = Some(capability);
}
}
#[test]
fn included_process_and_nested_scenario_evidence_is_identity_based_in_both_layers() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let (
work_kind,
sequence_kind,
capability_kind,
mission_kind,
performer_kind,
exchange_kind,
) = if layer == Layer::Oa {
(
Kind::Activity,
Kind::Process,
Kind::Capability,
Kind::OperationalMission,
Kind::Entity,
RelationKind::Interaction,
)
} else {
(
Kind::Function,
Kind::FunctionalChain,
Kind::SystemCapability,
Kind::Mission,
Kind::System,
RelationKind::FunctionalExchange,
)
};
let performer = add(&mut p, performer_kind, layer, "Service provider");
let a = add(&mut p, work_kind, layer, "Request service");
let b = add(&mut p, work_kind, layer, "Provide service");
for work in [a, b] {
p.relate(RelationKind::Allocation, "Performed work", performer, work);
}
let e = p.relate(exchange_kind, "Request", a, b);
let inner = add(&mut p, sequence_kind, layer, "Service path");
let outer = add(&mut p, sequence_kind, layer, "Reusable path");
occurrence(&mut p, inner, exchange(e));
occurrence(
&mut p,
outer,
OccurrenceBody::SequenceReference {
sequence: Some(inner),
},
);
let capability = add(&mut p, capability_kind, layer, "Deliver service");
p.relate(
RelationKind::ChainInvolvement,
"Service context",
outer,
capability,
);
let mission = add(&mut p, mission_kind, layer, "Serve users");
p.relate(
RelationKind::MissionCapability,
"Delivered capability",
mission,
capability,
);
let scenario = add(&mut p, Kind::Scenario, layer, "Conversation");
let sender = line(&mut p, scenario, a);
let receiver = line(&mut p, scenario, b);
let fragment = occurrence(
&mut p,
scenario,
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = occurrence(
&mut p,
fragment,
OccurrenceBody::Branch {
guard: "service_requested".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
let call = occurrence(
&mut p,
branch,
OccurrenceBody::Exchange {
operation: None,
exchange: Some(e),
sender: Some(sender),
receiver: Some(receiver),
message_kind: MessageKind::Call,
reply_to: None,
values: vec![],
},
);
occurrence(
&mut p,
branch,
OccurrenceBody::Exchange {
operation: None,
exchange: Some(e),
sender: Some(receiver),
receiver: Some(sender),
message_kind: MessageKind::Reply,
reply_to: Some(call),
values: vec![],
},
);
let reuse = add(&mut p, Kind::Scenario, layer, "Reused conversation");
let outer_sender = line(&mut p, reuse, a);
let outer_receiver = line(&mut p, reuse, b);
occurrence(
&mut p,
reuse,
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![
LifelineBinding {
referenced: sender,
local: outer_sender,
},
LifelineBinding {
referenced: receiver,
local: outer_receiver,
},
],
},
);
assert!(coverage(&p).is_empty());
let inner_steps = std::mem::take(p.elements.get_mut(&inner).unwrap().steps_mut().unwrap());
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.clear();
assert_eq!(
coverage(&p),
BTreeSet::from([
located("work_without_behavior_context", a, ""),
located("work_without_behavior_context", b, ""),
located("exchange_without_behavior_context", e, ""),
]),
"Unordered fragments and reused containers cannot activate omitted messages"
);
*p.elements.get_mut(&inner).unwrap().steps_mut().unwrap() = inner_steps;
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(fragment);
assert!(coverage(&p).is_empty());
// Removing every message leaves lifelines and reuse intact: neither is
// an execution claim. Empty sequences remain saveable, with old warnings.
for element in p.elements.values_mut() {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
}) = element.shared()
{
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: None,
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
}
}
assert_eq!(
coverage(&p),
BTreeSet::from([
located("work_without_behavior_context", a, ""),
located("work_without_behavior_context", b, ""),
located("exchange_without_behavior_context", e, "")
])
);
occurrence(&mut p, scenario, action(a)); // known work, unfinished lifeline
assert_eq!(
coverage(&p),
BTreeSet::from([
located("work_without_behavior_context", b, ""),
located("exchange_without_behavior_context", e, "")
])
);
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
}
}
#[test]
fn incomplete_oa_repairs_exact_records_and_ordered_membership_without_inference() {
let mut p = Project::empty();
let performer = add(&mut p, Kind::Entity, Layer::Oa, "Crew");
let parent = add(&mut p, Kind::Activity, Layer::Oa, "Manage service");
let a = p
.add_element(Kind::Activity, Layer::Oa, "Prepare", Some(parent))
.unwrap();
let b = p
.add_element(Kind::Activity, Layer::Oa, "Deliver", Some(parent))
.unwrap();
let c = add(&mut p, Kind::Activity, Layer::Oa, "Verify");
for work in [a, b, c] {
p.relate(RelationKind::Allocation, "Responsibility", performer, work);
}
let ab = p.relate(RelationKind::Interaction, "Prepared service", a, b);
let pc = p.relate(RelationKind::Interaction, "Grouped service", parent, c);
let ca = p.relate(RelationKind::Interaction, "Verified service", c, a);
let process = add(&mut p, Kind::Process, Layer::Oa, "Delivery path");
occurrence(&mut p, process, exchange(ab));
let mission = add(
&mut p,
Kind::OperationalMission,
Layer::Oa,
"Deliver reliably",
);
let capability = add(&mut p, Kind::Capability, Layer::Oa, "Reliable delivery");
p.relate(
RelationKind::CapabilityInvolvement,
"Crew support",
performer,
capability,
);
p.relate(
RelationKind::MissionInvolvement,
"Mission responsibility",
performer,
mission,
);
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Crew modes", Some(performer))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Crew region", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Available", Some(region))
.unwrap();
p.relate(RelationKind::StateAction, "Verification entry", state, c);
let observation = add(&mut p, Kind::Scenario, Layer::Oa, "Observation only");
let observed = line(&mut p, observation, c);
occurrence(
&mut p,
observation,
OccurrenceBody::State {
state: Some(state),
lifeline: Some(observed),
},
);
let expected = BTreeSet::from([
located("non_leaf_work_exchange", pc, "source"),
located("work_without_behavior_context", c, ""),
located("exchange_without_behavior_context", ca, ""),
located("mission_without_capability", mission, ""),
located("capability_without_behavior_context", capability, ""),
]);
assert_eq!(coverage(&p), expected);
let unordered = occurrence(&mut p, process, exchange(ca));
p.elements
.get_mut(&process)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != unordered);
assert_eq!(coverage(&p), expected);
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(unordered) && i.code == "unordered_occurrence")
);
let before = p.clone();
p.elements
.get_mut(&process)
.unwrap()
.steps_mut()
.unwrap()
.push(unordered);
if let RelationBody::Interaction { source, .. } = &mut p.relations.get_mut(&pc).unwrap().body {
*source = a;
}
occurrence(&mut p, process, exchange(pc));
p.relate(
RelationKind::MissionCapability,
"Mission delivery",
mission,
capability,
);
p.relate(
RelationKind::ChainInvolvement,
"Capability path",
process,
capability,
);
assert!(coverage(&p).is_empty());
// Public session admission and undo retain the previous exact warning set.
let mut session = Session::new(before.clone());
session
.execute(Command::Batch(diff_commands(&before, &p)))
.unwrap();
assert!(coverage(session.project()).is_empty());
session.undo();
assert_eq!(session.project(), &before);
assert_eq!(coverage(session.project()), expected);
}
#[test]
fn non_leaf_exchange_locations_use_work_endpoints_including_typed_sa_ports() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let (kind, relation_kind) = if layer == Layer::Oa {
(Kind::Activity, RelationKind::Interaction)
} else {
(Kind::Function, RelationKind::FunctionalExchange)
};
let source = add(&mut p, kind, layer, "Source group");
let target = add(&mut p, kind, layer, "Target group");
let a = p
.add_element(kind, layer, "Source leaf", Some(source))
.unwrap();
let b = p
.add_element(kind, layer, "Target leaf", Some(target))
.unwrap();
let edge = p.relate(relation_kind, "Decompose exchange", source, target);
assert_eq!(
coverage(&p),
BTreeSet::from([
located("non_leaf_work_exchange", edge, "source"),
located("non_leaf_work_exchange", edge, "target"),
located("work_without_behavior_context", a, ""),
located("work_without_behavior_context", b, ""),
])
);
if let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&edge].body
{
assert_eq!(p.elements[&source_port].parent, Some(source));
assert_eq!(p.elements[&target_port].parent, Some(target));
assert!(
coverage(&p)
.iter()
.all(|(_, id, _)| *id != source_port && *id != target_port)
);
}
}
}
#[test]
fn grouping_and_scenario_only_capabilities_do_not_duplicate_support_warnings() {
let mut p = Project::empty();
let parent = add(&mut p, Kind::Capability, Layer::Oa, "Capability group");
let leaf = p
.add_element(Kind::Capability, Layer::Oa, "Leaf capability", Some(parent))
.unwrap();
let issues = validation::validate(&p);
assert!(
issues
.iter()
.any(|i| i.element == Some(leaf) && i.code == "unsupported_capability")
);
assert!(
!issues
.iter()
.any(|i| i.element == Some(parent) && i.code.contains("capability"))
);
assert!(coverage(&p).is_empty());
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Capability scenario");
set_context(&mut p, scenario, leaf);
let issues = validation::validate(&p);
assert!(!issues.iter().any(|i| i.code == "unsupported_capability"));
assert!(
issues
.iter()
.any(|i| i.element == Some(scenario) && i.code == "empty_sequence")
);
assert!(coverage(&p).is_empty());
}
#[test]
fn structural_errors_suppress_completeness_cascades_and_rejection_keeps_the_model() {
let mut p = Project::empty();
let a = add(&mut p, Kind::Activity, Layer::Oa, "Request");
let b = add(&mut p, Kind::Activity, Layer::Oa, "Respond");
let e = p.relate(RelationKind::Interaction, "Request data", a, b);
let process = add(&mut p, Kind::Process, Layer::Oa, "Path");
let step = occurrence(&mut p, process, exchange(e));
assert!(coverage(&p).is_empty());
let mut bad = p.clone();
if let RelationBody::Interaction { source, .. } = &mut bad.relations.get_mut(&e).unwrap().body {
*source = Id::new_v4();
}
let mut wrong_owner = p.clone();
wrong_owner.elements.get_mut(&step).unwrap().parent = Some(a);
let mut recursive = p.clone();
let scenario = add(&mut recursive, Kind::Scenario, Layer::Oa, "Cycle");
occurrence(
&mut recursive,
scenario,
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![],
},
);
for (candidate, expected_error) in [
(bad, "invalid_endpoints"),
(wrong_owner, "invalid_occurrence"),
(recursive, "recursive_scenario_use"),
] {
let issues = validation::validate(&candidate);
assert!(
issues
.iter()
.any(|i| i.severity == Severity::Error && i.code == expected_error),
"{issues:?}"
);
assert!(
!issues.iter().any(|i| CODES.contains(&i.code.as_str())),
"{issues:?}"
);
let mut session = Session::new(p.clone());
assert!(
session
.execute(Command::Batch(diff_commands(&p, &candidate)))
.is_err()
);
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), 0);
}
}
#[test]
fn multifile_lint_preserves_core_codes_ids_and_actual_owning_source_paths() {
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
("project.arcadia".into(), "arcadia 5; project p \"Coverage\" { include \"work.arcadia\"; include \"exchanges.arcadia\"; }".into()),
("work.arcadia".into(), "oa_activity a \"Request\" {}\noa_activity b \"Respond\" {}".into()),
("exchanges.arcadia".into(), "interaction e \"Data\" { source @a; target @b; }".into()),
]) };
let compiled = compile_bundle(&source, ProjectLimits::default()).unwrap();
let expected = coverage(&compiled.decoded.project);
assert_eq!(expected.len(), 3);
let diagnostics: Vec<_> = lint_bundle(&compiled.bundle, ProjectLimits::default())
.into_iter()
.filter(|i| CODES.contains(&i.diagnostic.code.as_str()))
.collect();
let actual: BTreeSet<_> = diagnostics
.iter()
.map(|i| {
assert_eq!(i.diagnostic.severity, Severity::Warning);
let id = i.diagnostic.object_id.unwrap();
assert_eq!(i.path, compiled.index.declarations[&id].path);
assert!(i.diagnostic.span.end > i.diagnostic.span.start);
(i.diagnostic.code.clone(), id, i.diagnostic.field.clone())
})
.collect();
assert_eq!(actual, expected);
assert_eq!(diagnostics.len(), expected.len());
}
// Authoring-equivalent complete record edits keep the test independent of a
// private completeness helper and exercise actual transaction/undo boundaries.
fn diff_commands(before: &Project, after: &Project) -> Vec<Command> {
let mut commands = Vec::new();
for (id, element) in &after.elements {
match before.elements.get(id) {
None => commands.push(Command::CreateElement(element.clone())),
Some(old) if old != element => {
commands.push(Command::EditElement(*id, element.clone()))
}
_ => {}
}
}
for (id, relation) in &after.relations {
match before.relations.get(id) {
None => commands.push(Command::CreateRelation(relation.clone())),
Some(old) if old != relation => {
commands.push(Command::EditRelation(*id, relation.clone()))
}
_ => {}
}
}
commands
}
+38
View File
@@ -0,0 +1,38 @@
// Frozen authored schema3 compatibility input. Do not rewrite as current syntax.
arcadia 3;
project legacy_data "Legacy data contract" id "00000000-0000-0000-0000-000000000001" {
sa_function produce "Produce" id "00000000-0000-0000-0000-000000000002" {}
sa_function consume "Consume" id "00000000-0000-0000-0000-000000000003" {}
sa_exchange_item packet "Position event" id "00000000-0000-0000-0000-000000000004" {
kind Event;
// Reused record parameters retain their own identities.
sa_data_field origin "Origin" id "00000000-0000-0000-0000-000000000007" { type_ref { Named @coordinate; }; }
sa_data_field destination "Destination" id "00000000-0000-0000-0000-000000000008" { type_ref { Named @coordinate; }; }
}
sa_data_type coordinate "Coordinate" id "00000000-0000-0000-0000-000000000005" {
definition Record;
sa_data_field latitude "Latitude" id "00000000-0000-0000-0000-000000000006" { type_ref Real; unit "deg"; }
}
sa_interface sender_contract "Sender contract" id "00000000-0000-0000-0000-000000000009" { items [@packet]; }
sa_interface receiver_contract "Receiver contract" id "00000000-0000-0000-0000-00000000000a" { items [@packet]; }
sa_port output "Output" id "00000000-0000-0000-0000-00000000000b" {
parent @produce; contract { FunctionOutput { outgoing_items [@packet]; }; }; legacy none;
}
sa_port input "Input" id "00000000-0000-0000-0000-00000000000c" {
parent @consume; contract { FunctionInput { incoming_items [@packet]; }; }; legacy none;
}
functional_exchange position_exchange "Position exchange" id "00000000-0000-0000-0000-00000000000d" {
source @produce; target @consume; source_port @output; target_port @input; items [@packet];
}
sa_scenario scenario "Position scenario" id "00000000-0000-0000-0000-00000000000e" {
viewpoint Functional; steps [@message];
sa_lifeline sender "Sender" id "00000000-0000-0000-0000-00000000000f" { represented @produce; }
sa_lifeline receiver "Receiver" id "00000000-0000-0000-0000-000000000010" { represented @consume; }
sa_occurrence message "Position" id "00000000-0000-0000-0000-000000000011" {
body { Exchange {
exchange @position_exchange; sender @sender; receiver @receiver;
values [{ field @latitude; expression "navigation.latitude"; }];
}; };
}
}
}
+312
View File
@@ -0,0 +1,312 @@
{
"schema_version": 3,
"id": "00000000-0000-0000-0000-000000000001",
"alias": "legacy_data",
"name": "Legacy data contract",
"description": "",
"elements": {
"00000000-0000-0000-0000-000000000002": {
"id": "00000000-0000-0000-0000-000000000002",
"alias": "produce",
"name": "Produce",
"description": "",
"parent": null,
"body": {
"Sa": "Function"
}
},
"00000000-0000-0000-0000-000000000003": {
"id": "00000000-0000-0000-0000-000000000003",
"alias": "consume",
"name": "Consume",
"description": "",
"parent": null,
"body": {
"Sa": "Function"
}
},
"00000000-0000-0000-0000-000000000004": {
"id": "00000000-0000-0000-0000-000000000004",
"alias": "packet",
"name": "Position event",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"ExchangeItem": {
"kind": "Event"
}
}
}
}
},
"00000000-0000-0000-0000-000000000005": {
"id": "00000000-0000-0000-0000-000000000005",
"alias": "coordinate",
"name": "Coordinate",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"DataType": {
"definition": "Record"
}
}
}
}
},
"00000000-0000-0000-0000-000000000006": {
"id": "00000000-0000-0000-0000-000000000006",
"alias": "latitude",
"name": "Latitude",
"description": "",
"parent": "00000000-0000-0000-0000-000000000005",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": "Real",
"minimum": 1,
"maximum": 1,
"unit": "deg"
}
}
}
}
},
"00000000-0000-0000-0000-000000000007": {
"id": "00000000-0000-0000-0000-000000000007",
"alias": "origin",
"name": "Origin",
"description": "",
"parent": "00000000-0000-0000-0000-000000000004",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": {
"Named": "00000000-0000-0000-0000-000000000005"
},
"minimum": 1,
"maximum": 1,
"unit": ""
}
}
}
}
},
"00000000-0000-0000-0000-000000000008": {
"id": "00000000-0000-0000-0000-000000000008",
"alias": "destination",
"name": "Destination",
"description": "",
"parent": "00000000-0000-0000-0000-000000000004",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": {
"Named": "00000000-0000-0000-0000-000000000005"
},
"minimum": 1,
"maximum": 1,
"unit": ""
}
}
}
}
},
"00000000-0000-0000-0000-000000000009": {
"id": "00000000-0000-0000-0000-000000000009",
"alias": "sender_contract",
"name": "Sender contract",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Interface": {
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000a": {
"id": "00000000-0000-0000-0000-00000000000a",
"alias": "receiver_contract",
"name": "Receiver contract",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Interface": {
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000b": {
"id": "00000000-0000-0000-0000-00000000000b",
"alias": "output",
"name": "Output",
"description": "",
"parent": "00000000-0000-0000-0000-000000000002",
"body": {
"Sa": {
"Shared": {
"Port": {
"contract": {
"FunctionOutput": {
"outgoing_items": [
"00000000-0000-0000-0000-000000000004"
]
}
},
"legacy": null
}
}
}
}
},
"00000000-0000-0000-0000-00000000000c": {
"id": "00000000-0000-0000-0000-00000000000c",
"alias": "input",
"name": "Input",
"description": "",
"parent": "00000000-0000-0000-0000-000000000003",
"body": {
"Sa": {
"Shared": {
"Port": {
"contract": {
"FunctionInput": {
"incoming_items": [
"00000000-0000-0000-0000-000000000004"
]
}
},
"legacy": null
}
}
}
}
},
"00000000-0000-0000-0000-00000000000e": {
"id": "00000000-0000-0000-0000-00000000000e",
"alias": "scenario",
"name": "Position scenario",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Scenario": {
"viewpoint": "Functional",
"context": null,
"precondition": "",
"postcondition": "",
"steps": [
"00000000-0000-0000-0000-000000000011"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000f": {
"id": "00000000-0000-0000-0000-00000000000f",
"alias": "sender",
"name": "Sender",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Lifeline": {
"represented": "00000000-0000-0000-0000-000000000002"
}
}
}
}
},
"00000000-0000-0000-0000-000000000010": {
"id": "00000000-0000-0000-0000-000000000010",
"alias": "receiver",
"name": "Receiver",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Lifeline": {
"represented": "00000000-0000-0000-0000-000000000003"
}
}
}
}
},
"00000000-0000-0000-0000-000000000011": {
"id": "00000000-0000-0000-0000-000000000011",
"alias": "message",
"name": "Position",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Occurrence": {
"body": {
"Exchange": {
"exchange": "00000000-0000-0000-0000-00000000000d",
"sender": "00000000-0000-0000-0000-00000000000f",
"receiver": "00000000-0000-0000-0000-000000000010",
"message_kind": "Asynchronous",
"reply_to": null,
"values": [
{
"field": "00000000-0000-0000-0000-000000000006",
"expression": "navigation.latitude"
}
]
}
},
"precondition": "",
"postcondition": ""
}
}
}
}
}
},
"relations": {
"00000000-0000-0000-0000-00000000000d": {
"id": "00000000-0000-0000-0000-00000000000d",
"alias": "position_exchange",
"name": "Position exchange",
"description": "",
"body": {
"FunctionalExchange": {
"source": "00000000-0000-0000-0000-000000000002",
"target": "00000000-0000-0000-0000-000000000003",
"source_port": "00000000-0000-0000-0000-00000000000b",
"target_port": "00000000-0000-0000-0000-00000000000c",
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
},
"diagrams": {},
"artifact_links": {},
"traces": {}
}
+27
View File
@@ -0,0 +1,27 @@
// Frozen schema4 input: reviewed prose is deliberately different from live prose.
arcadia 4;
project reviewed_prose "Reviewed dimensions" id "40000000-0000-4000-8000-000000000001" {
description "Preserve the original review without inventing semantic history.";
oa_data_type oa_dimension "OA dimension now" id "40000000-0000-4000-8000-000000000002" {
description "Unreviewed OA description";
definition { Integer { minimum 0; maximum 100; }; };
default { Integer 20; };
unit "m";
}
sa_data_type sa_dimension "SA dimension now" id "40000000-0000-4000-8000-000000000003" {
description "Unreviewed SA description";
definition { Real { minimum 0.0; maximum 100.0; }; };
default { Real 20.0; };
unit "ft";
}
trace dimension_review "Dimension rationale" id "40000000-0000-4000-8000-000000000004" {
description "The target unit and representation are intentional.";
oa @oa_dimension;
sa @sa_dimension;
kind Item;
baseline {
oa { name "Approved OA name"; description "Approved OA\nprose"; };
sa { name "Approved SA name"; description "Approved SA prose"; };
};
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"schema_version": 4,
"id": "40000000-0000-4000-8000-000000000001",
"alias": "reviewed_prose",
"name": "Reviewed dimensions",
"description": "Preserve the original review without inventing semantic history.",
"elements": {
"40000000-0000-4000-8000-000000000002": {
"id": "40000000-0000-4000-8000-000000000002",
"alias": "oa_dimension",
"name": "OA dimension now",
"description": "Unreviewed OA description",
"parent": null,
"body": {"Oa": {"Shared": {"DataType": {
"definition": {"Integer": {"minimum": 0, "maximum": 100}},
"default": {"Integer": 20},
"unit": "m"
}}}}
},
"40000000-0000-4000-8000-000000000003": {
"id": "40000000-0000-4000-8000-000000000003",
"alias": "sa_dimension",
"name": "SA dimension now",
"description": "Unreviewed SA description",
"parent": null,
"body": {"Sa": {"Shared": {"DataType": {
"definition": {"Real": {"minimum": 0.0, "maximum": 100.0}},
"default": {"Real": 20.0},
"unit": "ft"
}}}}
}
},
"relations": {},
"diagrams": {},
"artifact_links": {},
"traces": {
"40000000-0000-4000-8000-000000000004": {
"id": "40000000-0000-4000-8000-000000000004",
"alias": "dimension_review",
"name": "Dimension rationale",
"description": "The target unit and representation are intentional.",
"oa": "40000000-0000-4000-8000-000000000002",
"sa": "40000000-0000-4000-8000-000000000003",
"kind": "Item",
"baseline": {
"oa": {"name": "Approved OA name", "description": "Approved OA\nprose"},
"sa": {"name": "Approved SA name", "description": "Approved SA prose"}
}
}
}
}
+177 -12
View File
@@ -168,6 +168,20 @@ project mission "Harbor safety — 港 🌊" {
.find(|e| e.alias == "event_record")
.unwrap()
.id;
let associated = p
.add_element(Kind::DataType, Layer::Sa, "Associated record", None)
.unwrap();
if let Some(SharedBody::DataType { definition, .. }) =
p.elements.get_mut(&associated).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record { supertype: None };
}
p.relate(
RelationKind::DataAssociation,
"Related record",
record,
associated,
);
for (index, type_ref) in [
TypeRef::Boolean,
TypeRef::Integer,
@@ -190,18 +204,30 @@ project mission "Harbor safety — 港 🌊" {
minimum: 1,
maximum: Some(5),
unit: "unit".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
for definition in [
DataTypeDefinition::Boolean,
DataTypeDefinition::Integer,
DataTypeDefinition::String,
DataTypeDefinition::Integer {
minimum: None,
maximum: None,
},
DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
] {
let id = p
.add_element(Kind::DataType, Layer::Sa, "Primitive type", None)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
SharedBody::DataType { definition };
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition,
default: None,
unit: String::new(),
};
}
for kind in [
ExchangeItemKind::Material,
@@ -216,7 +242,11 @@ project mission "Harbor safety — 港 🌊" {
None,
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind };
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
};
}
let scenario = p
.elements
@@ -309,6 +339,73 @@ project mission "Harbor safety — 港 🌊" {
component_port,
function_port,
);
let participants: Vec<_> = p
.elements
.values()
.filter(|e| matches!(e.kind(), Kind::Entity | Kind::Actor))
.map(|e| e.id)
.collect();
let means = p.relate(
RelationKind::CommunicationMean,
"Operational communication",
participants[0],
participants[1],
);
let category = p
.add_element(
Kind::ExchangeCategory,
Layer::Oa,
"Operational exchange category",
None,
)
.unwrap();
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
family: oa_core::view_options::ExchangeFamily::Participant,
exchanges: vec![means],
};
let caller = p
.add_element(Kind::Process, Layer::Oa, "Common work caller", None)
.unwrap();
let work = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let mut occurrences = vec![];
for name in ["First contribution", "Second contribution"] {
let id = p
.add_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
occurrences.push(id);
}
*p.elements.get_mut(&caller).unwrap().steps_mut().unwrap() = occurrences.clone();
let id = p.relate(
RelationKind::ContextualLink,
"Shared work incidence",
occurrences[0],
occurrences[1],
);
p.relations.get_mut(&id).unwrap().body = RelationBody::ContextualLink {
owner: caller,
source: OccurrenceEndpoint {
references: vec![],
occurrence: occurrences[0],
},
target: OccurrenceEndpoint {
references: vec![],
occurrence: occurrences[1],
},
purpose: ContextualLinkPurpose::CommonWork { work },
};
p
}
@@ -461,7 +558,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
#[test]
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
for (source, expected) in [
("arcadia 4; project mission \"x\" {}", "Unsupported schema"),
("arcadia 8; project mission \"x\" {}", "Unsupported schema"),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
"Unknown field",
@@ -765,9 +862,27 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(from_source, from_json);
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
let mut retained = serde_json::to_value(&from_json).unwrap();
assert_eq!(retained["schema_version"], 3);
assert_eq!(retained["schema_version"], oa_core::SCHEMA_VERSION);
retained["schema_version"] = 2.into();
let allocations: Vec<_> = from_json
.relations
.values()
.filter_map(|relation| {
if let RelationBody::InterfaceItemAllocation {
source,
target,
order,
..
} = relation.body
{
Some((source, target, order))
} else {
None
}
})
.collect();
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
let element_id: Id = element["id"].as_str().unwrap().parse().unwrap();
for layer in ["Oa", "Sa"] {
let Some(shared) = element["body"]
.get_mut(layer)
@@ -779,6 +894,46 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(port["contract"], "Legacy");
*port = port["legacy"].clone();
}
if let Some(item) = shared
.get_mut("ExchangeItem")
.and_then(serde_json::Value::as_object_mut)
{
if item["mechanism"] == "Event" {
item.insert("kind".into(), "Event".into());
}
item.remove("mechanism");
item.remove("supertype");
}
if let Some(kind) = shared
.get_mut("DataType")
.and_then(serde_json::Value::as_object_mut)
{
kind.remove("default");
kind.remove("unit");
let definition = &kind["definition"];
if let Some(name) = ["Integer", "Real", "String", "Record"]
.into_iter()
.find(|name| definition.get(name).is_some())
{
kind.insert("definition".into(), name.into());
}
}
if let Some(field) = shared
.get_mut("DataField")
.and_then(serde_json::Value::as_object_mut)
{
for key in ["direction", "storage", "default"] {
field.remove(key);
}
}
if let Some(interface) = shared.get_mut("Interface") {
let mut entries: Vec<_> = allocations
.iter()
.filter(|(source, _, _)| *source == element_id)
.collect();
entries.sort_by_key(|(_, _, order)| *order);
*interface = serde_json::json!({"items":entries.into_iter().map(|(_,target,_)|target).collect::<Vec<_>>()});
}
if let Some(scenario) = shared
.get_mut("Scenario")
.and_then(serde_json::Value::as_object_mut)
@@ -797,6 +952,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
}
}
}
retained["relations"]
.as_object_mut()
.unwrap()
.retain(|_, relation| relation["body"].get("InterfaceItemAllocation").is_none());
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
assert_eq!(boundary["oriented"], true);
@@ -812,6 +971,11 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
}
}
for diagram in retained["diagrams"].as_object_mut().unwrap().values_mut() {
assert_eq!(
diagram["view_options"],
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
);
diagram.as_object_mut().unwrap().remove("view_options");
assert_eq!(diagram["routes"], serde_json::json!([]));
diagram.as_object_mut().unwrap().remove("routes");
}
@@ -826,7 +990,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
);
assert_eq!(
from_json.relations.len(),
original["relations"].as_object().unwrap().len()
original["relations"].as_object().unwrap().len() + allocations.len()
);
assert_eq!(
from_json.diagrams.len(),
@@ -899,7 +1063,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
assert_eq!(from_text, from_json);
assert_eq!(read(text), from_text);
assert_eq!(persistence::decode(json).unwrap(), from_json);
assert_eq!(from_text.schema_version, 3);
assert_eq!(from_text.schema_version, oa_core::SCHEMA_VERSION);
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
for (id, record) in raw["elements"].as_object().unwrap() {
@@ -1098,9 +1262,10 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
break;
}
}
if code.trim_start().starts_with("arcadia 2;")
|| code.trim_start().starts_with("arcadia 3;")
{
if (1..=SCHEMA_VERSION).any(|version| {
code.trim_start()
.starts_with(&format!("arcadia {version};"))
}) {
read(&code);
examples += 1;
}
+20 -5
View File
@@ -39,7 +39,10 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
assert_eq!(decoded.project, from_source.project);
assert_eq!(decoded.migration, from_source.migration);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (2, 3));
assert_eq!(
(report.from_schema, report.to_schema),
(2, oa_core::SCHEMA_VERSION)
);
assert_eq!(report.preserved_ports.len(), 4);
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.orientation_defaults.len(), 1);
@@ -56,9 +59,15 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
+ old["traces"].as_object().unwrap().len()
+ old["artifact_links"].as_object().unwrap().len()
+ 1
+ report.generated_interface_allocations.len()
);
let mut current = serde_json::to_value(&decoded.project).unwrap();
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
assert_eq!(
diagram["view_options"],
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
);
diagram.as_object_mut().unwrap().remove("view_options");
assert_eq!(diagram["routes"], json!([]), "V2 adds no authored routing");
diagram.as_object_mut().unwrap().remove("routes");
}
@@ -143,7 +152,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
}
}
let canonical = language::format(&first.project);
assert!(canonical.starts_with("arcadia 3;"));
assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, first.project);
@@ -223,7 +232,13 @@ fn migration_admission_counts_generated_objects_records_and_references() {
_ => 0,
})
})
.sum::<usize>();
.sum::<usize>()
+ decode(&old)
.migration
.unwrap()
.generated_interface_allocations
.len()
* 2;
for limits in [
ParseLimits {
max_graph_objects: graph + 1,
@@ -244,7 +259,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
));
}
let accepted = ParseLimits {
max_graph_objects: graph + 2,
max_graph_objects: migrated.elements.len() + migrated.relations.len(),
max_records: records,
max_step_references: reference_count,
..ParseLimits::UNLIMITED
@@ -267,7 +282,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
assert!(error.contains("V2") && error.contains(&fe), "{error}");
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
let result = persistence::decode_with_report(v1).unwrap();
assert_eq!(result.project.schema_version, 3);
assert_eq!(result.project.schema_version, oa_core::SCHEMA_VERSION);
assert_eq!(result.migration.unwrap().from_schema, 1);
assert_eq!(persistence::decode(v1).unwrap(), result.project);
}
+11 -6
View File
@@ -1,6 +1,6 @@
use oa_core::{language, persistence, *};
const SOURCE: &str = r#"arcadia 3;
const SOURCE: &str = r#"arcadia 4;
project typed_ports "Typed ports" {
sa_system producer "Producer" {}
sa_actor consumer "Consumer" {}
@@ -9,7 +9,8 @@ project typed_ports "Typed ports" {
allocation sends "Produces" { source @producer; target @send; }
allocation receives "Consumes" { source @consumer; target @receive; }
sa_exchange_item data "Data" {}
sa_interface service "Service" { items [@data]; }
sa_interface service "Service" {}
interface_item_allocation service_data "Service data" { source @service; target @data; }
sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; }
sa_port input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }
sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; }
@@ -26,9 +27,13 @@ project typed_ports "Typed ports" {
"#;
#[test]
fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
let decoded = language::parse_with_report(SOURCE).unwrap();
assert!(decoded.migration.is_none());
let migration = decoded.migration.as_ref().unwrap();
assert_eq!(
(migration.from_schema, migration.to_schema),
(4, oa_core::SCHEMA_VERSION)
);
let canonical = language::format(&decoded.project);
for field in [
"incoming_items [@data]",
@@ -69,7 +74,7 @@ fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references
}
#[test]
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
for (remove, expected) in [
("source_port @output; ", "source_port"),
("target_port @input; ", "target_port"),
@@ -90,7 +95,7 @@ fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation()
}
#[test]
fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() {
fn source4_rejects_mixed_component_kinds_and_nested_reference_typos() {
let mixed = SOURCE.replace(
"parent @consumer; contract { Component { kind Standard; orientation Unset;",
"parent @consumer; contract { Component { kind Flow; orientation Unset;",
+684
View File
@@ -0,0 +1,684 @@
use oa_core::{transitions::*, *};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Family {
Data,
Port,
Occurrence,
Message,
Event,
State,
}
const FAMILIES: [Family; 6] = [
Family::Data,
Family::Port,
Family::Occurrence,
Family::Message,
Family::Event,
Family::State,
];
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
p.add_element(kind, layer, "reviewed", parent).unwrap()
}
fn trace(p: &mut Project, oa: Id, sa: Id) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("review_{}", id.simple()),
name: "Review".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
id
}
fn set(p: &mut Project, id: Id, body: SharedBody) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
}
fn integer(p: &mut Project, id: Id) {
set(
p,
id,
SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(10),
},
default: None,
unit: "m".into(),
},
);
}
fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector) {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let id = match family {
Family::Data => {
let id = add(&mut p, Kind::DataType, layer, None);
integer(&mut p, id);
id
}
Family::Port => {
let kind = if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
};
let owner = add(&mut p, kind, layer, None);
let id = Id::new_v4();
let mut port = make_port(&p, id, owner, "port", PortContract::Legacy).unwrap();
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
*legacy = Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
});
}
p.elements.insert(id, port);
id
}
Family::Occurrence | Family::Message => {
let owner = add(&mut p, Kind::Scenario, layer, None);
let id = add(&mut p, Kind::Occurrence, layer, Some(owner));
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
set(
&mut p,
id,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Timer {
expression: "10s".into(),
},
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
},
);
id
}
Family::Event => {
let id = add(&mut p, Kind::Event, layer, None);
set(
&mut p,
id,
SharedBody::Event {
body: EventBody::Time {
kind: TimeEventKind::After,
expression: "10s".into(),
},
},
);
id
}
Family::State => {
let machine = add(&mut p, Kind::StateMachine, layer, None);
let region = add(&mut p, Kind::Region, layer, Some(machine));
add(&mut p, Kind::State, layer, Some(region))
}
};
pair.push(id);
}
let (oa, sa) = (pair[0], pair[1]);
let t = trace(&mut p, oa, sa);
let (constructor, descendant) = match family {
Family::Data => (
FacetSelector::Data(DataFacet::Constructor),
FacetSelector::Data(DataFacet::IntegerMaximum),
),
Family::Port => (
FacetSelector::Port(PortFacet::Constructor),
FacetSelector::Port(PortFacet::LegacyDirection),
),
Family::Occurrence => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
),
Family::Message => (
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
),
Family::Event => (
FacetSelector::Event(EventFacet::Constructor),
FacetSelector::Event(EventFacet::TimeExpression),
),
Family::State => (
FacetSelector::State(StateFacet::Kind),
FacetSelector::State(StateFacet::Entry),
),
};
(p, oa, sa, t, constructor, descendant)
}
fn change_descendant(p: &mut Project, id: Id) {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::DataType {
definition: DataTypeDefinition::Integer { maximum, .. },
..
} => *maximum = Some(20),
SharedBody::Port {
legacy: Some(LegacyPortContract { direction, .. }),
..
} => *direction = PortDirection::Out,
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Timer { expression },
..
},
..
}
| SharedBody::Event {
body: EventBody::Time { expression, .. },
} => *expression = "20s".into(),
SharedBody::State { entry, .. } => *entry = "initialize".into(),
_ => panic!("Unexpected test family"),
}
}
fn request(
oa: Id,
sa: Id,
reverse: bool,
resolutions: Vec<(FacetSelector, SemanticDecision)>,
) -> SemanticTransitionRequest {
let (direction, source, target) = if reverse {
(TransitionDirection::SaToOa, sa, oa)
} else {
(TransitionDirection::OaToSa, oa, sa)
};
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut r = SemanticTransitionRequest::new(transition);
r.reviews.push(SemanticReviewRequest {
source,
resolutions: resolutions
.into_iter()
.map(|(facet, decision)| FacetResolution { facet, decision })
.collect(),
reset: vec![],
});
r
}
fn apply(p: &Project, r: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
let plan = plan_semantic_transition(p, r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
(apply_semantic_transition(p, &plan).unwrap(), plan)
}
fn drift(p: &Project, t: Id, selector: &FacetSelector) -> Drift {
semantic_facets(p, &p.traces[&t], &BTreeMap::new())
.unwrap()
.into_iter()
.find(|f| f.selector == *selector)
.unwrap()
.drift
}
#[test]
fn first_equal_constructor_acknowledges_only_discriminator_in_every_family_and_direction() {
for family in FAMILIES {
for reverse in [false, true] {
for decision in [
SemanticDecision::KeepTarget,
SemanticDecision::TakeSource,
SemanticDecision::Candidate,
] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(family);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
change_descendant(&mut p, target);
let mut r = request(oa, sa, reverse, vec![(constructor.clone(), decision)]);
if decision == SemanticDecision::Candidate {
r.candidates
.push(SemanticRecord::Element(p.elements[&target].clone()));
}
let (q, plan) = apply(&p, r.clone());
assert_eq!(q.elements, p.elements, "{family:?} {decision:?}");
assert_eq!(plan.reviews[0].acknowledged, vec![constructor.clone()]);
assert!(plan.reviews[0].retired.is_empty());
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert!(facts.iter().all(|f| f.drift
== if f.selector == constructor {
Drift::Unchanged
} else {
Drift::Unreviewed
}));
let (mut q, _) = apply(
&q,
request(
oa,
sa,
reverse,
vec![(descendant.clone(), SemanticDecision::KeepTarget)],
),
);
change_descendant(&mut q, source);
assert_eq!(
drift(&q, t, &descendant),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
let repeated = plan_semantic_transition(&q, r).unwrap();
assert!(
repeated
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&q, &repeated).is_err());
}
}
}
}
#[test]
fn independent_constructor_and_leaf_selections_complete_missing_or_empty_baselines() {
for family in FAMILIES {
for reverse in [false, true] {
for history in 0..3 {
let (mut p, oa, sa, t, _, _) = fixture(family);
if history == 0 {
p.traces.get_mut(&t).unwrap().baseline = None;
} else if history == 2 {
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline::default());
}
let selectors: Vec<_> = semantic_facets(&p, &p.traces[&t], &BTreeMap::new())
.unwrap()
.into_iter()
.map(|f| f.selector)
.collect();
let (q, plan) = apply(
&p,
request(
oa,
sa,
reverse,
selectors
.iter()
.cloned()
.map(|s| (s, SemanticDecision::KeepTarget))
.collect(),
),
);
assert_eq!(q.elements, p.elements);
assert_eq!(plan.reviews[0].acknowledged.len(), selectors.len());
assert!(plan.reviews[0].retired.is_empty());
let status = trace_semantic_drift(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert_eq!(status.own, Drift::Unchanged, "{family:?} {history}");
assert!(!status.own_unreviewed);
}
}
}
}
#[test]
fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes() {
let json = include_str!("fixtures/v4-reviewed-prose.json")
.replace("\"Real\"", "\"Integer\"")
.replace("0.0", "0")
.replace("100.0", "100")
.replace("20.0", "20");
for reverse in [false, true] {
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
let report = decoded.migration.unwrap();
assert_eq!(
(report.from_schema, report.to_schema),
(4, oa_core::SCHEMA_VERSION)
);
let p = decoded.project;
let t = p.traces.values().next().unwrap();
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
let original_prose = t.baseline.clone().unwrap();
let (oa, sa, id) = (t.oa, t.sa, t.id);
let selections = semantic_facets(&p, t, &BTreeMap::new())
.unwrap()
.into_iter()
.map(|f| (f.selector, SemanticDecision::KeepTarget))
.collect();
let (q, _) = apply(&p, request(oa, sa, reverse, selections));
assert_eq!(q.elements, p.elements);
let baseline = q.traces[&id].baseline.as_ref().unwrap();
assert_eq!(baseline.oa, original_prose.oa);
assert_eq!(baseline.sa, original_prose.sa);
let status = trace_semantic_drift(&q, &q.traces[&id], &BTreeMap::new()).unwrap();
assert_eq!(status.own, Drift::Unchanged);
assert!(!status.own_unreviewed);
}
}
#[test]
fn equal_constructor_candidate_requires_independent_selection_of_changed_descendants() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let target = if reverse { oa } else { sa };
let mut changed = p.clone();
change_descendant(&mut changed, target);
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::Candidate)],
);
r.candidates
.push(SemanticRecord::Element(changed.elements[&target].clone()));
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
assert!(
invalid
.conflicts
.iter()
.any(|c| c.contains("changes an unselected facet"))
);
assert!(apply_semantic_transition(&p, &invalid).is_err());
r.reviews[0].resolutions.push(FacetResolution {
facet: descendant.clone(),
decision: SemanticDecision::Candidate,
});
let (q, plan) = apply(&p, r);
assert_eq!(q.elements, changed.elements);
assert_eq!(plan.reviews[0].acknowledged.len(), 2);
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
assert_eq!(
drift(&q, t, &FacetSelector::Data(DataFacet::Default)),
Drift::Unreviewed
);
}
}
#[test]
fn reset_equal_constructor_does_not_refresh_previously_reviewed_descendants() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let (mut p, _) = apply(
&p,
request(
oa,
sa,
reverse,
vec![
(constructor.clone(), SemanticDecision::KeepTarget),
(descendant.clone(), SemanticDecision::KeepTarget),
],
),
);
let original = p.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.clone();
change_descendant(&mut p, if reverse { sa } else { oa });
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.reviews[0].reset.push(constructor.clone());
let (q, plan) = apply(&p, r);
assert_eq!(q.elements, p.elements);
assert_eq!(plan.reviews[0].acknowledged, vec![constructor]);
assert!(plan.reviews[0].retired.is_empty());
assert_eq!(
q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap(),
&original
);
assert_eq!(
drift(&q, t, &descendant),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
}
}
#[test]
fn first_constructor_acknowledgement_agrees_only_on_its_canonical_discriminator_slot() {
for switch in [false, true] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let second = add(&mut p, Kind::DataType, Layer::Oa, None);
integer(&mut p, second);
if switch {
set(
&mut p,
second,
SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.),
maximum: Some(30.),
},
default: None,
unit: "m".into(),
},
);
} else {
change_descendant(&mut p, second);
}
trace(&mut p, second, sa);
let mut r = request(
oa,
sa,
false,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.transition.sources.push(second);
r.transition.mappings.push(TransitionMapping {
source: second,
target: Some(sa),
target_kind: None,
});
r.reviews.push(SemanticReviewRequest {
source: second,
reset: vec![],
resolutions: vec![FacetResolution {
facet: if switch {
constructor.clone()
} else {
descendant.clone()
},
decision: SemanticDecision::TakeSource,
}],
});
for reversed in [false, true] {
let mut r = r.clone();
if reversed {
r.reviews.reverse();
}
let plan = plan_semantic_transition(&p, r).unwrap();
if switch {
assert!(plan.conflicts.iter().any(|c| c.contains("Contradictory")));
assert!(apply_semantic_transition(&p, &plan).is_err());
} else {
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_semantic_transition(&p, &plan).unwrap();
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
assert_eq!(drift(&q, t, &descendant), Drift::Unreviewed);
assert!(matches!(
q.elements[&sa].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
maximum: Some(20),
..
},
..
})
));
}
}
}
}
#[test]
fn one_sided_or_disappeared_message_constructor_is_not_an_equal_scalar_acknowledgement() {
for reverse in [false, true] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Message);
let source = if reverse { sa } else { oa };
let target = if reverse { oa } else { sa };
let mut action = p.elements[&source].shared().unwrap().clone();
if let SharedBody::Occurrence { body, .. } = &mut action {
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
}
set(&mut p, source, action.clone());
let (q, plan) = apply(
&p,
request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
),
);
// One-sided applicability still has a constructor-group scope and records its timer leaf.
assert_eq!(
plan.reviews[0].acknowledged,
vec![constructor.clone(), descendant.clone()]
);
assert_eq!(q.elements, p.elements);
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
let mut p = q;
set(&mut p, target, action);
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.reviews[0].reset.push(constructor);
let plan = plan_semantic_transition(&p, r).unwrap();
assert!(!plan.conflicts.is_empty());
assert!(apply_semantic_transition(&p, &plan).is_err());
}
}
#[test]
fn historical_switches_retain_groups_and_completed_requests_still_reject_in_both_directions() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, _) = fixture(Family::Data);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
let keep = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
let (mut p, _) = apply(&p, keep.clone());
set(
&mut p,
source,
SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.),
maximum: Some(20.),
},
default: None,
unit: "m".into(),
},
);
let (mut q, plan) = apply(&p, keep.clone());
let maximum = FacetSelector::Data(DataFacet::RealMaximum);
assert!(plan.reviews[0].acknowledged.contains(&maximum));
assert!(
plan.reviews[0]
.acknowledged
.contains(&FacetSelector::Data(DataFacet::Default))
);
assert!(
!plan.reviews[0]
.acknowledged
.contains(&FacetSelector::Data(DataFacet::Unit))
);
assert_eq!(q.elements, p.elements);
if let Some(SharedBody::DataType {
definition: DataTypeDefinition::Real { maximum, .. },
..
}) = q.elements.get_mut(&source).unwrap().shared_mut()
{
*maximum = Some(30.);
}
let repeated = plan_semantic_transition(&q, keep).unwrap();
assert!(
repeated
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&q, &repeated).is_err());
assert_eq!(
drift(&q, t, &maximum),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
let take = request(
oa,
sa,
reverse,
vec![(constructor, SemanticDecision::TakeSource)],
);
let (p, _) = apply(&q, take.clone());
assert!(matches!(
p.elements[&target].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Real {
maximum: Some(30.),
..
},
..
})
));
let completed = plan_semantic_transition(&p, take).unwrap();
assert!(
completed
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&p, &completed).is_err());
}
}
+175
View File
@@ -0,0 +1,175 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::Value;
use std::collections::BTreeMap;
const JSON4: &[u8] = include_bytes!("fixtures/v4-reviewed-prose.json");
const SOURCE4: &str = include_str!("fixtures/v4-reviewed-prose.arcadia");
#[test]
fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semantics() {
let json = persistence::decode_with_report(JSON4).unwrap();
let source = language::parse_with_report(SOURCE4).unwrap();
assert_eq!(json.project, source.project);
for decoded in [json, source] {
assert_eq!(decoded.project.schema_version, oa_core::SCHEMA_VERSION);
let report = decoded.migration.unwrap();
assert_eq!(
(report.from_schema, report.to_schema),
(4, oa_core::SCHEMA_VERSION)
);
let trace = decoded.project.traces.values().next().unwrap();
let baseline = trace.baseline.as_ref().unwrap();
assert!(baseline.semantic.is_none());
assert_eq!(baseline.oa.name, "Approved OA name");
assert_eq!(baseline.oa.description, "Approved OA\nprose");
assert_eq!(baseline.sa.name, "Approved SA name");
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
expected["schema_version"] = oa_core::SCHEMA_VERSION.into();
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
let reopened =
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
.unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, decoded.project);
}
}
#[test]
fn older_headers_cannot_admit_new_semantic_baselines() {
let mut value: Value = serde_json::from_slice(JSON4).unwrap();
let trace = value["traces"]
.as_object_mut()
.unwrap()
.values_mut()
.next()
.unwrap();
trace["baseline"]["semantic"] = serde_json::json!({"facets": []});
for version in [3, 4] {
value["schema_version"] = version.into();
let error = persistence::decode(&serde_json::to_vec(&value).unwrap())
.unwrap_err()
.to_string();
assert!(error.contains("semantic"), "{error}");
}
let source = SOURCE4.replace("baseline {", "baseline { semantic { facets []; };");
assert!(
language::parse(&source)
.unwrap_err()
.iter()
.any(|d| d.message.contains("require schema/source5"))
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap_err()
.iter()
.any(|d| d.diagnostic.message.contains("require schema/source5"))
);
}
#[test]
fn frozen_v4_trace_envelope_keeps_duplicate_and_unknown_field_rejection() {
let json = std::str::from_utf8(JSON4).unwrap();
let duplicate = json.replace("\"baseline\": {", "\"baseline\": null, \"baseline\": {");
assert!(
persistence::decode(duplicate.as_bytes())
.unwrap_err()
.to_string()
.contains("duplicate field")
);
let unknown = json.replace("\"baseline\": {", "\"baseline\": { \"reviewed\": true,");
assert!(
persistence::decode(unknown.as_bytes())
.unwrap_err()
.to_string()
.contains("unknown field")
);
}
#[test]
fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
let start = SOURCE4.find(" oa_data_type").unwrap();
let end = SOURCE4.rfind('}').unwrap();
let entry = format!(
"{} include \"contracts.arcadia\";\n}}\n",
&SOURCE4[..start]
);
let mut module =
SOURCE4[start..end].replace("baseline {", "baseline { // original review rationale");
module.push_str(
r#"
oa_data_type record "Independent record" id "40000000-0000-4000-8000-000000000011" {
definition { Record { supertype none; }; };
// This field remains inside its authored record.
oa_data_field flag "Flag" id "40000000-0000-4000-8000-000000000012" { type_ref Boolean; }
}
"#,
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), entry.clone()),
("contracts.arcadia".into(), module.clone()),
]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
assert_eq!(
compiled.bundle.files["project.arcadia"],
entry.replacen("arcadia 4;", &format!("arcadia {SCHEMA_VERSION};"), 1)
);
assert_eq!(
compiled.index.lexical_owners
[&Id::parse_str("40000000-0000-4000-8000-000000000012").unwrap()],
Id::parse_str("40000000-0000-4000-8000-000000000011").unwrap()
);
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert!(reopened.decoded.migration.is_none());
assert_eq!(reopened.bundle, compiled.bundle);
assert_eq!(reopened.decoded.project, compiled.decoded.project);
assert_eq!(
bundle.files["project.arcadia"], entry,
"caller draft is unchanged"
);
}
#[test]
fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
for (version, bytes) in [
(
1,
include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(),
),
(
2,
include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(),
),
(
3,
include_bytes!("fixtures/v3-data-contract.json").as_slice(),
),
] {
let decoded = persistence::decode_with_report(bytes).unwrap();
let report = decoded.migration.unwrap();
assert_eq!(
(report.from_schema, report.to_schema),
(version, oa_core::SCHEMA_VERSION)
);
assert_eq!(decoded.project.schema_version, oa_core::SCHEMA_VERSION);
assert!(
decoded
.project
.traces
.values()
.all(|t| t.baseline.as_ref().is_none_or(|b| b.semantic.is_none()))
);
assert_eq!(
language::parse(&language::format(&decoded.project)).unwrap(),
decoded.project
);
}
}
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
use oa_core::{language, persistence, semantic::*, source_project::*, *};
use std::collections::BTreeMap;
fn id(n: u128) -> Id {
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
}
fn reviewed<T>(oa: T, sa: T) -> Reviewed<T> {
Reviewed {
oa: Observed::Value(oa),
sa: Observed::Value(sa),
}
}
fn project(facets: Vec<FacetReview>) -> Project {
let mut project =
persistence::decode(include_bytes!("fixtures/v4-reviewed-prose.json")).unwrap();
project
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline { facets });
project
}
fn facets(project: &mut Project) -> &mut Vec<FacetReview> {
&mut project
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic
.as_mut()
.unwrap()
.facets
}
fn compile(source: String) -> CompiledSourceProject {
compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
},
ProjectLimits::default(),
)
.unwrap()
}
#[test]
fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories() {
let row = MessageValue {
path: vec![id(91), id(92)],
field: id(93),
expression: "x + 1".into(),
};
let model = project(vec![
FacetReview::Data(DataReview::Constructor(reviewed(
DataConstructor::Integer,
DataConstructor::Real,
))),
FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
oa: Observed::Value(None),
sa: Observed::NotApplicable,
})),
FacetReview::Data(DataReview::RealMaximum(Reviewed {
oa: Observed::NotApplicable,
sa: Observed::Value(Some(1.5)),
})),
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
))),
FacetReview::Field(FieldReview::Default(reviewed(
Some(vec![DataValue::String(id(90).to_string())]),
None,
))),
FacetReview::Member {
collection: ReferenceCollection::Steps,
keys: KeyPair {
oa: Some(id(91)),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Order {
collection: OrderedCollection::Steps,
review: reviewed(vec![id(91), id(92)], vec![]),
},
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey::from(&row)),
sa: None,
},
review: reviewed(Some(row), None),
},
FacetReview::Binding {
keys: KeyPair {
oa: Some(id(94)),
sa: None,
},
review: reviewed(
Some(LifelineBinding {
referenced: id(94),
local: id(95),
}),
None,
),
},
FacetReview::Literal {
keys: KeyPair {
oa: Some(id(90).to_string()),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Artifact {
keys: KeyPair {
oa: Some(id(96)),
sa: None,
},
review: ArtifactReview::Relation(reviewed(
ArtifactRelation::References,
ArtifactRelation::References,
)),
},
]);
assert!(!model.contains(id(90)));
let source = language::format(&model);
assert!(source.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
assert!(
source.contains(&format!("Named uuid \"{}\"", id(90))),
"{source}"
);
assert!(source.contains("sa NotApplicable;"));
assert!(source.contains("Value none;"));
assert!(source.contains(&format!("String \"{}\"", id(90))));
assert_eq!(language::parse(&source).unwrap(), model);
assert_eq!(compile(source).decoded.project, model);
assert_eq!(
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
model
);
}
#[test]
fn historical_uuid_and_enum_tokens_are_contextual_and_text_remains_quoted() {
let model = project(vec![
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(2)),
TypeRef::Boolean,
))),
FacetReview::Data(DataReview::Unit(reviewed(id(2).to_string(), "m".into()))),
]);
let source = language::format(&model);
let reference = format!("Named uuid \"{}\"", id(2));
for replacement in [format!("Named \"{}\"", id(2)), "Named @oa_dimension".into()] {
let invalid = source.replace(&reference, &replacement);
assert!(language::parse(&invalid).is_err(), "{invalid}");
assert!(
compile_bundle(
&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), invalid)])
},
ProjectLimits::default()
)
.is_err()
);
}
let invalid_text = source.replace("Value \"m\";", &format!("Value uuid \"{}\";", id(2)));
assert!(language::parse(&invalid_text).is_err());
let invalid_enum = source.replace("Value Boolean;", "Value \"Boolean\";");
assert!(language::parse(&invalid_enum).is_err());
let invalid_live = source.replace("oa @oa_dimension;", &format!("oa uuid \"{}\";", id(2)));
assert!(language::parse(&invalid_live).is_err());
let invalid_prose = source.replace(
"name \"Approved OA name\";",
&format!("name uuid \"{}\";", id(2)),
);
assert!(language::parse(&invalid_prose).is_err());
// `uuid` remains an ordinary legal declaration alias after @.
assert!(language::parse(&source.replace("oa_dimension", "uuid")).is_ok());
}
#[test]
fn semantic_values_remain_closed_typed_records_in_both_formats() {
let model = project(vec![FacetReview::Data(DataReview::IntegerMinimum(
reviewed(Some(0), None),
))]);
let source = language::format(&model);
for invalid in [
source.replace("IntegerMinimum", "UnknownFacet"),
source.replace("Value 0;", "Value \"0\";"),
source.replace("facets [", "unexpected true; facets ["),
] {
assert!(language::parse(&invalid).is_err(), "{invalid}");
}
let mut json = serde_json::to_value(&model).unwrap();
json["traces"][id(4).to_string()]["baseline"]["semantic"] = serde_json::json!({"facets": [{"Data": {"IntegerMinimum": {"oa": {"Value": "0"}, "sa": {"Value": null}}}}]});
assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err());
}
#[test]
fn facet_reordering_and_reidentification_keep_comments_with_typed_row_identity() {
let row = MessageValue {
path: vec![id(2)],
field: id(90),
expression: id(2).to_string(),
};
let model = project(vec![
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey::from(&row)),
sa: None,
},
review: reviewed(Some(row), None),
},
FacetReview::Literal {
keys: KeyPair {
oa: Some(id(2).to_string()),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(2)),
TypeRef::Boolean,
))),
]);
let source = language::format(&model)
.replace("Annotation {", "Annotation { // annotation rationale")
.replace("Literal {", "Literal { // literal rationale")
.replace("Named uuid ", "Named uuid // historical type rationale\n");
let current = compile(source);
let mut prepared = prepare_command(
&current.decoded.project,
&Command::Reidentify(id(2), id(20)),
)
.unwrap();
facets(&mut prepared.project).reverse();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let text = &updated.bundle.files["project.arcadia"];
assert_eq!(updated.decoded.project, prepared.project);
assert!(
text.contains(&format!(
"Named uuid // historical type rationale\n\"{}\"",
id(20)
)),
"{text}"
);
let type_note = text.find("// historical type rationale").unwrap();
let literal_note = text.find("// literal rationale").unwrap();
let annotation_note = text.find("// annotation rationale").unwrap();
assert!(
type_note < literal_note && literal_note < annotation_note,
"{text}"
);
assert!(text.contains(&format!("expression \"{}\"", id(2))));
let FacetReview::Literal { keys, .. } = &facets(&mut prepared.project)[1] else {
panic!()
};
assert_eq!(
keys.oa,
Some(id(2).to_string()),
"UUID-shaped literal keys are text"
);
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.bundle,
updated.bundle
);
}
#[test]
fn historical_reference_deletion_does_not_remove_the_review_or_create_a_live_alias() {
let mut model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
)))]);
let mut former_type = model.elements[&id(2)].clone();
former_type.id = id(90);
former_type.alias = "former_type".into();
model.elements.insert(id(90), former_type);
let current = compile(language::format(&model));
let prepared = prepare_command(&current.decoded.project, &Command::Delete(id(90))).unwrap();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let source = &updated.bundle.files["project.arcadia"];
assert!(!updated.decoded.project.contains(id(90)));
assert!(!source.contains("@former_type"));
assert!(source.contains(&format!("Named uuid \"{}\"", id(90))));
assert_eq!(updated.decoded.project.traces, model.traces);
assert_eq!(language::parse(source).unwrap(), prepared.project);
}
#[test]
fn included_historical_review_edits_save_and_reopen_without_rewriting_other_modules() {
use oa_core::source_files::{load_project, save_project};
let model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
)))]);
let source = language::format(&model);
let trace_at = source.find(" trace ").unwrap();
let end = source.rfind('}').unwrap();
let entry = format!(
"{} include \"reviews.arcadia\";\n}}\n",
&source[..trace_at]
);
let review =
source[trace_at..end].replace("Named uuid ", "Named uuid // deleted type retained\n");
let directory = tempfile::tempdir().unwrap();
let entry_path = directory.path().join("project.arcadia");
std::fs::write(&entry_path, &entry).unwrap();
std::fs::write(directory.path().join("reviews.arcadia"), &review).unwrap();
let snapshot = load_project(&entry_path, ProjectLimits::default()).unwrap();
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_eq!(compiled.index.declarations[&id(4)].path, "reviews.arcadia");
let mut changed = compiled.decoded.project.clone();
let FacetReview::Field(FieldReview::Type(value)) = &mut facets(&mut changed)[0] else {
panic!()
};
value.sa = Observed::Value(TypeRef::String);
let updated = update_bundle(&compiled, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.bundle.files["project.arcadia"], entry);
let saved = save_project(&snapshot, &updated, ProjectLimits::default()).unwrap();
let reopened = load_project(&entry_path, ProjectLimits::default()).unwrap();
assert_eq!(saved.bundle, reopened.bundle);
assert_eq!(
compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), entry);
assert!(reopened.bundle.files["reviews.arcadia"].contains("uuid // deleted type retained"));
assert!(!changed.contains(id(90)));
}
#[test]
fn prepared_semantic_review_writes_back_and_preserves_the_accepted_target_difference() {
use oa_core::transitions::{TransitionDirection, TransitionMapping, TransitionRequest};
let model = project(vec![]);
let current = compile(language::format(&model).replace(
"baseline {",
"baseline { // prior prose review is independent",
));
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![id(2)]);
transition.mappings.push(TransitionMapping {
source: id(2),
target: Some(id(3)),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source: id(2),
resolutions: vec![
FacetResolution {
facet: FacetSelector::Data(DataFacet::Unit),
decision: SemanticDecision::KeepTarget,
},
FacetResolution {
facet: FacetSelector::Header(HeaderFacet::Name),
decision: SemanticDecision::TakeSource,
},
],
reset: vec![],
});
let plan = plan_semantic_transition(&current.decoded.project, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let prepared = prepare_semantic_transition(&current.decoded.project, &plan).unwrap();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(
updated.decoded.project.elements[&id(3)].name,
"OA dimension now"
);
assert!(
matches!(updated.decoded.project.elements[&id(3)].shared(), Some(SharedBody::DataType { unit, .. }) if unit == "ft")
);
assert!(
updated.bundle.files["project.arcadia"].contains("// prior prose review is independent")
);
let baseline = updated.decoded.project.traces[&id(4)]
.baseline
.as_ref()
.unwrap();
assert_eq!(baseline.oa.name, "Approved OA name");
assert!(
baseline
.semantic
.as_ref()
.unwrap()
.facets
.contains(&FacetReview::Data(DataReview::Unit(reviewed(
"m".into(),
"ft".into()
))))
);
assert_eq!(
language::parse(&language::format(&updated.decoded.project)).unwrap(),
prepared.project
);
assert_eq!(
persistence::decode(&serde_json::to_vec(&updated.decoded.project).unwrap()).unwrap(),
prepared.project
);
}
File diff suppressed because it is too large Load Diff
+231
View File
@@ -0,0 +1,231 @@
use oa_core::{language, persistence, *};
use serde_json::{Value, json};
const SOURCE3: &str = include_str!("fixtures/v3-data-contract.arcadia");
fn id(n: u128) -> Id {
Id::from_u128(n)
}
// Frozen JSON uses the same authored schema3 contract as the source fixture;
// this helper never serializes current defaults under version3.
fn original3() -> Value {
serde_json::from_str(include_str!("fixtures/v3-data-contract.json")).unwrap()
}
#[test]
fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() {
let old = original3();
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
let source = language::parse_with_report(SOURCE3).unwrap();
assert_eq!(decoded.project, source.project);
assert_eq!(decoded.migration, source.migration);
let project = &decoded.project;
let report = decoded.migration.as_ref().unwrap();
assert_eq!(
(report.from_schema, report.to_schema),
(3, oa_core::SCHEMA_VERSION)
);
assert_eq!(project.identities().len(), 19);
assert!(report.preserved_ports.is_empty());
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.generated_interface_allocations.len(), 2);
for allocation in &report.generated_interface_allocations {
assert_eq!(
allocation.id,
Id::new_v5(
&id(1),
format!(
"v2-interface-item:{}:{}",
allocation.source, allocation.target
)
.as_bytes()
)
);
assert_eq!(allocation.target, id(4));
assert_eq!(allocation.order, 0);
assert!(matches!(
project.relations[&allocation.id].body,
RelationBody::InterfaceItemAllocation {
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
..
}
));
}
let now = serde_json::to_value(project).unwrap();
for port in [id(11), id(12)] {
assert_eq!(
now["elements"][port.to_string()],
old["elements"][port.to_string()]
);
}
assert!(matches!(
project.elements[&id(4)].shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Event,
supertype: None
})
));
assert_eq!(report.unresolved_message_values.len(), 1);
assert!(
validation::validate(project)
.iter()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
let canonical = language::format(project);
assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
let reopened = language::parse_with_report(&canonical).unwrap();
assert_eq!(&reopened.project, project);
assert!(reopened.migration.is_none());
assert_eq!(canonical, language::format(&reopened.project));
}
#[test]
fn v3_changed_payloads_reject_schema4_fields_and_new_constructor_names() {
let mut old = original3();
old["elements"][id(4).to_string()]["body"]["Sa"]["Shared"]["ExchangeItem"]["mechanism"] =
json!("Flow");
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let mut old = original3();
old["elements"][id(5).to_string()]["body"]["Sa"]["Shared"]["DataType"]["definition"] =
json!({"Record":{"supertype":null}});
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let mut old = original3();
old["elements"][id(17).to_string()]["body"]["Sa"]["Shared"]["Occurrence"]["body"]["Exchange"]
["values"][0]["path"] = json!([]);
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let source = SOURCE3.replace("kind Event;", "kind Information; mechanism Flow;");
assert!(language::parse(&source).is_err());
let source = SOURCE3.replace(
"functional_exchange position_exchange",
"interface_item_allocation position_exchange",
);
assert!(language::parse(&source).is_err());
}
#[test]
fn deterministic_allocation_id_collision_is_located_without_overwriting_any_object() {
let mut old = original3();
let collision = Id::new_v5(
&id(1),
format!("v2-interface-item:{}:{}", id(9), id(4)).as_bytes(),
);
old["artifact_links"][collision.to_string()] = json!({"id":collision,"alias":"retained_artifact","name":"Retain","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
let error = persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap_err();
match error {
persistence::FileError::Model { issues, .. } => {
assert_eq!(issues[0].code, "identity_collision");
assert_eq!(issues[0].element, Some(id(9)));
assert_eq!(issues[0].field.as_deref(), Some("items"));
}
other => panic!("unexpected error {other}"),
}
}
#[test]
fn unique_legacy_nested_value_is_qualified_and_physical_nature_is_not_guessed() {
let source = SOURCE3.replace("kind Event;", "kind Material;").replace(
"type_ref { Named @coordinate; }; }\n }",
"type_ref String; }\n }",
);
let decoded = language::parse_with_report(&source).unwrap();
assert!(matches!(
decoded.project.elements[&id(4)].shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material,
mechanism: ExchangeMechanism::Unspecified,
..
})
));
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = decoded.project.elements[&id(17)].shared()
else {
panic!()
};
assert_eq!(values[0].path, vec![id(7)]);
assert_eq!(values[0].expression, "navigation.latitude");
}
#[test]
fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
let v2 =
persistence::decode_with_report(include_bytes!("fixtures/v2-coastal-rescue.json")).unwrap();
let text =
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
assert_eq!(v2.project, text.project);
assert_eq!(v2.migration, text.migration);
assert_eq!(v2.migration.unwrap().to_schema, oa_core::SCHEMA_VERSION);
let v1 = language::parse_with_report(
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
)
.unwrap();
assert_eq!(v1.project.schema_version, oa_core::SCHEMA_VERSION);
assert_eq!(v1.migration.unwrap().from_schema, 1);
}
#[test]
fn allocation_alias_collisions_are_stable_and_duplicate_legacy_payload_fields_reject() {
let mut old = original3();
let first = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
let alias = first.migration.unwrap().generated_interface_allocations[0]
.alias
.clone();
old["artifact_links"][id(100).to_string()] = json!({"id":id(100),"alias":alias,"name":"Existing alias","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
let bytes = serde_json::to_vec(&old).unwrap();
let migrated = persistence::decode_with_report(&bytes).unwrap();
let generated = &migrated
.migration
.as_ref()
.unwrap()
.generated_interface_allocations[0];
assert_ne!(generated.alias, alias);
assert!(generated.alias.starts_with(&alias));
assert_eq!(
migrated.project,
persistence::decode_with_report(&bytes).unwrap().project
);
let duplicate = include_str!("fixtures/v3-data-contract.json").replace(
"\"kind\": \"Event\"",
"\"kind\": \"Event\", \"kind\": \"Material\"",
);
assert!(
persistence::decode(duplicate.as_bytes())
.unwrap_err()
.to_string()
.contains("Duplicate field 'kind'")
);
}
#[test]
fn recursive_legacy_record_paths_remain_ambiguous_instead_of_becoming_one_argument() {
let mut old = original3();
old["elements"][id(8).to_string()]["body"]["Sa"]["Shared"]["DataField"]["type_ref"] =
json!("String");
old["elements"][id(100).to_string()] = json!({"id":id(100),"alias":"next_coordinate","name":"Next coordinate","description":"","parent":id(5),"body":{"Sa":{"Shared":{"DataField":{"type_ref":{"Named":id(5)},"minimum":0,"maximum":1,"unit":""}}}}});
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
assert_eq!(
decoded
.migration
.as_ref()
.unwrap()
.unresolved_message_values
.len(),
1
);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = decoded.project.elements[&id(17)].shared()
else {
panic!()
};
assert!(values[0].path.is_empty());
assert!(
validation::validate(&decoded.project)
.iter()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
}
+217
View File
@@ -0,0 +1,217 @@
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn id(n: u128) -> Id {
Id::from_u128(n)
}
#[test]
fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_identity() {
let original = include_str!("fixtures/v3-data-contract.arcadia");
let header = original.find(" sa_function").unwrap();
let close = original.rfind('}').unwrap();
let entry = format!(
"{} // Contracts stay in their original module.\n include \"contracts.arcadia\";\n}}\n",
&original[..header]
);
let fragment = original[header..close].replace(
"items [@packet];",
"items [@ // interface contract decision\npacket];",
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), entry),
("contracts.arcadia".into(), fragment),
]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files.len(), 2);
assert!(
compiled.bundle.files["project.arcadia"].contains(&format!("arcadia {SCHEMA_VERSION};"))
);
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
for text in [
"// Contracts stay in their original module.",
"// Reused record parameters retain their own identities.",
"// interface contract decision",
] {
assert!(
compiled
.bundle
.files
.values()
.any(|source| source.contains(text)),
"Lost {text}"
);
}
assert_eq!(compiled.index.lexical_owners[&id(6)], id(5));
assert_eq!(compiled.index.lexical_owners[&id(7)], id(4));
assert_eq!(compiled.index.lexical_owners[&id(8)], id(4));
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
let report = compiled.decoded.migration.as_ref().unwrap();
assert_eq!(report.from_schema, 3);
for allocation in &report.generated_interface_allocations {
assert_eq!(
compiled.index.declarations[&allocation.id].path,
"contracts.arcadia"
);
}
assert!(
compiled
.diagnostics
.iter()
.any(|d| d.diagnostic.code == "message_value_ambiguous_path")
);
let second = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(second.bundle, compiled.bundle);
assert_eq!(second.decoded.project, compiled.decoded.project);
assert!(second.decoded.migration.is_none());
let mut renamed = compiled.decoded.project.clone();
renamed.elements.get_mut(&id(6)).unwrap().name = "Latitude degrees".into();
let updated = update_bundle(&compiled, &renamed, ProjectLimits::default()).unwrap();
assert_eq!(updated.index.lexical_owners, compiled.index.lexical_owners);
assert!(updated.bundle.files["contracts.arcadia"].contains("Latitude degrees"));
assert!(
bundle.files["project.arcadia"].contains("arcadia 3;"),
"Migration does not mutate the caller's draft"
);
}
#[test]
fn schema4_typed_values_supertype_paths_and_protocols_roundtrip() {
let source = r#"arcadia 4; project data_contract "Data contract" {
sa_data_type bounded "Bounded" { definition { Integer { minimum -9223372036854775808; maximum 9223372036854775807; }; }; default { Integer -9223372036854775808; }; unit "m"; }
sa_data_type label_type "Label" { definition { String { minimum_length 1; maximum_length 4; }; }; default { String "航海"; }; }
sa_data_type base "Base" { definition { Record { supertype none; }; }; }
sa_data_type derived "Derived" { definition { Record { supertype @base; }; }; }
sa_exchange_item operation "Operation" { mechanism Operation;
sa_data_field value "Value" { type_ref { Named @bounded; }; direction In; storage Value; default [{ Integer 42; }]; }
}
sa_interface contract "Contract" { supertype none; }
interface_item_allocation member "Operation member" { source @contract; target @operation; transmission Synchronous; acquisition Accept; order 3; }
data_association association "Related records" { source @base; target @derived; source_role "parent"; target_role "children"; source_minimum 0; source_maximum 1; target_minimum 0; target_maximum none; source_navigable true; target_navigable true; }
}"#;
let parsed = language::parse(source).unwrap();
let formatted = language::format(&parsed);
assert!(formatted.contains("supertype @base"));
assert!(formatted.contains("transmission Synchronous"));
assert_eq!(language::parse(&formatted).unwrap(), parsed);
let compiled = compile_bundle(&format_bundle(&parsed), ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, parsed);
assert!(compiled.decoded.migration.is_none());
}
#[test]
fn omitted_ids_are_stabilized_before_allocation_identity_generation() {
let bundle=SourceBundle{entry:"project.arcadia".into(),files:BTreeMap::from([("project.arcadia".into(),"arcadia 3; project old_model \"Old\" { sa_exchange_item event_item \"Event\" { kind Event; } sa_interface contract \"Contract\" { items [@event_item]; } }".into())])};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let report = compiled.decoded.migration.as_ref().unwrap();
let allocation = &report.generated_interface_allocations[0];
assert_eq!(
allocation.id,
Id::new_v5(
&compiled.decoded.project.id,
format!(
"v2-interface-item:{}:{}",
allocation.source, allocation.target
)
.as_bytes()
)
);
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reopened.decoded.project, compiled.decoded.project);
assert_eq!(reopened.bundle, compiled.bundle);
}
#[test]
fn inferring_a_legacy_path_keeps_rationale_inside_its_argument() {
let source = include_str!("fixtures/v3-data-contract.arcadia")
.replace(
"type_ref { Named @coordinate; }; }\n }",
"type_ref String; }\n }",
)
.replace(
"field @latitude; expression",
"field @latitude; // original argument rationale\nexpression",
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let source = &compiled.bundle.files["project.arcadia"];
let argument = &source[source.find("values [{").unwrap()..];
let argument = &argument[..argument.find('}').unwrap()];
assert!(
argument.contains("// original argument rationale"),
"{source}"
);
assert!(argument.contains("path [@origin]"), "{source}");
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
}
#[test]
fn qualified_value_reordering_and_path_reidentification_keep_each_arguments_comments() {
let mut project = language::parse(include_str!("fixtures/v3-data-contract.arcadia")).unwrap();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
else {
panic!()
};
*values = vec![
MessageValue {
path: vec![id(7)],
field: id(6),
expression: "origin value".into(),
},
MessageValue {
path: vec![id(8)],
field: id(6),
expression: "destination value".into(),
},
];
let mut bundle = format_bundle(&project);
for source in bundle.files.values_mut() {
*source = source
.replace(
"expression \"origin value\";",
"expression \"origin value\"; // origin argument rationale",
)
.replace(
"expression \"destination value\";",
"expression \"destination value\"; // destination argument rationale",
);
}
let current = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
else {
panic!()
};
values.reverse();
let renamed = Id::from_u128(100);
let mut identities: BTreeMap<_, _> = project
.identities()
.into_iter()
.map(|(id, _)| (id, id))
.collect();
identities.insert(id(7), renamed);
reidentify(&mut project, id(7), renamed).unwrap();
project.elements.get_mut(&renamed).unwrap().alias = "revised_origin".into();
let result =
update_bundle_with_identity_map(&current, &project, &identities, ProjectLimits::default())
.unwrap();
let source = &result.bundle.files[&result.index.declarations[&id(17)].path];
let destination = source.find("// destination argument rationale").unwrap();
let origin = source.find("// origin argument rationale").unwrap();
assert!(
destination < origin,
"Distinct uses of the same terminal field must reorder as complete argument annotations: {source}"
);
assert!(source[origin..].contains("path [@revised_origin]"));
assert_eq!(result.decoded.project, project);
}
+256
View File
@@ -0,0 +1,256 @@
//! Engineering oracles for the distributable current-profile lesson.
#[path = "../../../examples/release1/model.rs"]
mod teaching;
use oa_core::{projection, source_project, transitions::*, validation, *};
use teaching::id;
#[test]
fn release1_teaching_regeneration_and_shipped_source_are_exact_with_one_intentional_warning() {
let first = teaching::build();
let second = teaching::build();
assert_eq!(
first.project, second.project,
"fresh planner UUIDs must not churn the teaching source"
);
assert_eq!(first.before_transition, second.before_transition);
assert_eq!(first.request, second.request);
let bundle = teaching::checked_bundle(&first.project);
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/release1");
for (name, bytes) in bundle.files {
assert_eq!(
std::fs::read_to_string(root.join(&name)).unwrap(),
bytes,
"regenerate {name}"
);
}
assert_eq!(
persistence::decode(&std::fs::read(root.join("model.json")).unwrap()).unwrap(),
first.project
);
for (path, expected) in [
(
"transition-before.json",
serde_json::to_value(&first.before_transition).unwrap(),
),
(
"transition-request.json",
serde_json::to_value(&first.request).unwrap(),
),
(
"expected-diagnostics.json",
serde_json::to_value(validation::validate(&first.project)).unwrap(),
),
] {
let shipped: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join(path)).unwrap()).unwrap();
assert_eq!(shipped, expected, "regenerate {path}");
}
let issues = validation::validate(&first.project);
assert_eq!(issues.len(), 1, "{issues:?}");
assert_eq!(issues[0].severity, validation::Severity::Warning);
assert_eq!(issues[0].code, "work_without_behavior_context");
assert_eq!(issues[0].element, Some(id("sa_audit_thresholds")));
assert!(
first
.project
.elements
.values()
.filter(|e| e.layer() == Layer::Oa)
.all(|e| e.kind() != Kind::Port)
);
}
#[test]
fn release1_teaching_preserves_explicit_transport_direction_reuse_and_view_identity() {
let lesson = teaching::build();
let p = &lesson.project;
for prefix in ["oa", "sa"] {
assert!(
matches!(p.elements[&id(&format!("{prefix}_assess_step"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::SequenceReference { sequence: Some(target) }, .. }) if *target == id(&format!("{prefix}_assessment_procedure")))
);
assert!(
matches!(p.elements[&id(&format!("{prefix}_assessment_action"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Action { action: Some(target), .. }, .. }) if *target == id(&format!("{prefix}_assess")))
);
assert!(
p.relations
.values()
.any(|relation| relation.kind() == RelationKind::ChainInvolvement
&& relation.source() == id(&format!("{prefix}_assessment_procedure"))
&& relation.target() == id(&format!("{prefix}_triage")))
);
assert!(
matches!(p.elements[&id(&format!("{prefix}_conversation"))].shared(), Some(SharedBody::Scenario { context: Some(context), .. }) if *context == id(&format!("{prefix}_restore")))
);
assert!(p.relations.values().any(|relation| relation.kind()
== RelationKind::CapabilityInvolvement
&& relation.source() == id(&format!("{prefix}_assess"))
&& relation.target() == id(&format!("{prefix}_triage"))));
let call = &p.elements[&id(&format!("{prefix}_call"))];
let reply = &p.elements[&id(&format!("{prefix}_reply"))];
let body = |e: &Element| {
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
body.clone()
} else {
panic!()
}
};
let validation::MessageEndpointResolution::Resolved { source, target } =
validation::resolve_message_endpoints(p, &body(call))
else {
panic!("call not resolved")
};
assert_eq!(
validation::resolve_message_endpoints(p, &body(reply)),
validation::MessageEndpointResolution::Resolved {
source: target,
target: source
}
);
assert!(
matches!(p.elements[&id(&format!("{prefix}_use_field"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::SequenceReference { sequence: Some(target) }, .. }) if *target == id(&format!("{prefix}_field_procedure")))
);
assert!(
matches!(p.elements[&id(&format!("{prefix}_use_conversation"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: Some(target), bindings }, .. }) if *target == id(&format!("{prefix}_conversation")) && bindings.len() == 2)
);
}
let full = projection::project_view(p, id("sa_allocation_view"));
let focused = projection::project_view(p, id("sa_service_view"));
let edges: std::collections::BTreeSet<_> = full.edges.iter().map(|e| e.id).collect();
assert_eq!(
edges,
[
id("sa_contact"),
id("sa_repair_request"),
id("sa_repair_report")
]
.into()
);
assert_eq!(focused.edges.len(), 1);
assert_eq!(focused.edges[0].id, id("sa_contact"));
assert!(
p.diagrams[&id("sa_service_view")]
.view_options
.exchange_labels
.label(p, focused.edges[0].id)
.contains("Valve repair")
);
let ports = |view: &projection::ProjectedView| {
view.ports
.iter()
.map(|v| (v.element, v.position))
.collect::<std::collections::BTreeMap<_, _>>()
};
assert_eq!(
ports(&full),
ports(&focused),
"filtering must not move the shared technical port geometry"
);
assert!(
p.traces
.values()
.any(|t| t.oa == id("oa_contact") && t.sa == id("sa_contact"))
);
}
#[test]
fn release1_teaching_rejects_guessed_ports_and_wrong_performer_without_changing_source() {
let lesson = teaching::build();
let mut missing_decisions = lesson.request.clone();
missing_decisions.communication_endpoints.clear();
let plan = plan_transition(&lesson.before_transition, &missing_decisions).unwrap();
assert!(
!plan.conflicts.is_empty(),
"an operational channel does not determine technical ports"
);
let compiled = source_project::compile_bundle(
&teaching::checked_bundle(&lesson.project),
source_project::ProjectLimits::default(),
)
.unwrap();
let mut wrong = lesson.project.relations[&id("oa_repair_request")].clone();
wrong.body.set_endpoints(id("oa_assess"), id("oa_reset"));
assert!(
execute_batch(
&lesson.project,
&[Command::EditRelation(wrong.id, wrong.clone())]
)
.is_err()
);
let mut candidate = lesson.project.clone();
candidate.relations.insert(wrong.id, wrong);
assert!(
validation::validate(&candidate)
.iter()
.any(|i| i.severity == validation::Severity::Error
&& i.code == "exchange_allocation_performer")
);
assert!(
source_project::update_bundle(
&compiled,
&candidate,
source_project::ProjectLimits::default()
)
.is_err()
);
assert_eq!(compiled.decoded.project, lesson.project);
}
#[test]
fn release1_teaching_review_changes_only_the_selected_counterpart_and_preserves_modules() {
let lesson = teaching::build();
let mut changed = lesson.project.clone();
changed.elements.get_mut(&id("oa_confirm")).unwrap().name = "Confirm stable pressure".into();
let compiled = source_project::compile_bundle(
&teaching::checked_bundle(&changed),
source_project::ProjectLimits::default(),
)
.unwrap();
let mut transition =
TransitionRequest::new(TransitionDirection::OaToSa, vec![id("oa_confirm")]);
transition.fields.clear();
transition.mappings.push(TransitionMapping {
source: id("oa_confirm"),
target: Some(id("sa_confirm")),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source: id("oa_confirm"),
resolutions: vec![FacetResolution {
facet: FacetSelector::Header(HeaderFacet::Name),
decision: SemanticDecision::TakeSource,
}],
reset: vec![],
});
let plan = plan_semantic_transition(&changed, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let accepted = apply_semantic_transition(&changed, &plan).unwrap();
assert_eq!(
accepted.elements[&id("sa_confirm")].name,
"Confirm stable pressure"
);
assert_eq!(
accepted.elements[&id("sa_control")].name,
lesson.project.elements[&id("sa_control")].name
);
let updated = source_project::update_bundle(
&compiled,
&accepted,
source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, accepted);
assert_eq!(
updated.bundle.files.keys().collect::<Vec<_>>(),
compiled.bundle.files.keys().collect::<Vec<_>>()
);
for (path, original) in &compiled.bundle.files {
if path != "sa/functions/function_sa_restore_work.arcadia" && path != "traceability.arcadia"
{
assert_eq!(
&updated.bundle.files[path], original,
"unrelated module changed: {path}"
);
}
}
}
+65 -16
View File
@@ -447,11 +447,12 @@ fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_r
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
let (source, target) = p.relations[&boundary].body.endpoints();
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
let requirement = p.relate(
RelationKind::InterfaceRequirement,
"Caller requires service",
@@ -499,11 +500,12 @@ fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_
let (mut p, a, b, item, forward) = fixture();
let (old, _) = endpoints(&p, forward);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
@@ -659,11 +661,12 @@ fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity()
let (mut p, _, _, item, fe) = fixture();
let output = endpoints(&p, fe).0;
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
@@ -730,12 +733,36 @@ fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
0
);
let (mut p, a, b, _, fe) = fixture();
allocate(&mut p, a, b);
let (system, partner) = allocate(&mut p, a, b);
let component_req = component_request(&p, fe, ComponentPortKind::Flow);
let boundary = boundary_id(&component_req);
p = execute_batch(&p, &plan_component_exchange(&p, &component_req).unwrap()).unwrap();
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![fe, boundary]);
reverse.scope = DependencyScope::Required;
// A boundary now has a portless OA CommunicationMean counterpart. Its
// participant owners require an explicit operational boundary decision.
let incomplete = plan_transition(&p, &reverse).unwrap();
for owner in [system, partner] {
assert!(
incomplete
.conflicts
.iter()
.any(|conflict| conflict.source == Some(owner)
&& conflict.message.contains("counterpart kind explicitly"))
);
}
reverse.mappings = vec![
TransitionMapping {
source: system,
target: None,
target_kind: Some(Kind::Entity),
},
TransitionMapping {
source: partner,
target: None,
target_kind: Some(Kind::Actor),
},
];
let plan = plan_transition(&p, &reverse).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
@@ -754,6 +781,28 @@ fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
.values()
.any(|r| r.kind() == RelationKind::Interaction)
);
let mean = result
.traces
.values()
.find(|trace| trace.sa == boundary)
.unwrap()
.oa;
let relation = &result.relations[&mean];
assert_eq!(relation.kind(), RelationKind::CommunicationMean);
assert_eq!(result.elements[&relation.source()].kind(), Kind::Entity);
assert_eq!(result.elements[&relation.target()].kind(), Kind::Actor);
assert!(
result
.traces
.values()
.any(|trace| trace.sa == system && trace.oa == relation.source())
);
assert!(
result
.traces
.values()
.any(|trace| trace.sa == partner && trace.oa == relation.target())
);
for e in p.elements.values() {
assert_eq!(result.elements[&e.id], *e);
}
+1
View File
@@ -39,6 +39,7 @@ fn fixture() -> Fixture {
description: String::new(),
layer: Layer::Sa,
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
+5 -1
View File
@@ -51,6 +51,7 @@ fn message(
call: Option<Id>,
) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange,
sender: Some(sender),
receiver,
@@ -94,7 +95,9 @@ fn fixture(layer: Layer) -> Fixture {
.add_element(Kind::DataType, layer, "Request record", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record,
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let field = p
.add_element(Kind::DataField, layer, "Requested level", Some(record))
@@ -160,6 +163,7 @@ fn fixture(layer: Layer) -> Fixture {
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
values.push(MessageValue {
path: vec![payload],
field,
expression: "requested_level".into(),
});
+1
View File
@@ -271,6 +271,7 @@ fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() {
owner,
"Contextual message",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(global),
sender: None,
receiver: None,
@@ -0,0 +1,140 @@
//! Independent GUI boundary: Presence observes attachment, not record existence.
use oa_core::{transitions::*, *};
fn artifact(p: &mut Project, owner: Id, name: &str) -> Id {
let id = Id::new_v4();
p.artifact_links.insert(
id,
ArtifactLink {
id,
alias: format!("artifact_{}", id.simple()),
name: name.into(),
description: String::new(),
owner,
relation: ArtifactRelation::References,
uri: "urn:engineering:requirement".into(),
revision: None,
fragment: Some(String::new()),
},
);
id
}
fn fixture(reverse: bool) -> (Project, Id, Id, TransitionDirection) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "OA owner", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "SA owner", None)
.unwrap();
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.simple()),
name: "Owner review".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
if reverse {
(p, sa, oa, TransitionDirection::SaToOa)
} else {
(p, oa, sa, TransitionDirection::OaToSa)
}
}
fn removal(
source: Id,
target: Id,
direction: TransitionDirection,
source_link: Option<Id>,
target_link: Id,
) -> SemanticTransitionRequest {
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
let keys = if direction == TransitionDirection::OaToSa {
KeyPair {
oa: source_link,
sa: Some(target_link),
}
} else {
KeyPair {
oa: Some(target_link),
sa: source_link,
}
};
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa) {
request.references.insert(oa, sa);
}
request.reviews.push(SemanticReviewRequest {
source,
reset: vec![],
resolutions: vec![FacetResolution {
facet: FacetSelector::Artifact {
keys,
facet: ArtifactFacet::Presence,
},
decision: SemanticDecision::TakeSource,
}],
});
request
}
#[test]
fn target_only_artifact_take_preserves_without_fabricated_source_identity() {
for reverse in [false, true] {
let (mut p, source, target, direction) = fixture(reverse);
let link = artifact(&mut p, target, "Target-only requirement");
let before = p.clone();
let plan =
plan_semantic_transition(&p, removal(source, target, direction, None, link)).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(p, before, "planning must be pure");
let after = apply_semantic_transition(&p, &plan).unwrap();
assert_eq!(after.artifact_links, before.artifact_links);
assert_eq!(after.elements, before.elements);
assert_eq!(after.relations, before.relations);
}
}
#[test]
fn moved_away_source_artifact_counts_as_absent_from_reviewed_owner() {
for reverse in [false, true] {
let (mut p, source, target, direction) = fixture(reverse);
let (kind, layer) = if reverse {
(Kind::Function, Layer::Sa)
} else {
(Kind::Activity, Layer::Oa)
};
let elsewhere = p
.add_element(kind, layer, "Moved source owner", None)
.unwrap();
let source_link = artifact(&mut p, elsewhere, "Still exists elsewhere");
let target_link = artifact(&mut p, target, "Old counterpart attachment");
let original_source = p.artifact_links[&source_link].clone();
let plan = plan_semantic_transition(
&p,
removal(source, target, direction, Some(source_link), target_link),
)
.unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let after = apply_semantic_transition(&p, &plan).unwrap();
assert!(!after.artifact_links.contains_key(&target_link));
assert_eq!(after.artifact_links[&source_link], original_source);
}
}
@@ -0,0 +1,398 @@
//! Direct core oracles for the next existing-record graphical attachment slice.
use oa_core::{transitions::*, *};
struct Fixture {
p: Project,
owners: [Id; 2],
attached: [Id; 2],
outside: [Id; 2],
trace: Id,
child: bool,
end: IncidentEnd,
}
fn trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.simple()),
name: name.into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
id
}
fn fixture(child: bool, end: IncidentEnd) -> Fixture {
let mut p = Project::empty();
let mut owners = vec![];
let mut attached = vec![];
let mut outside = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let kind = if child {
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
}
} else {
Kind::DataType
};
let owner = p.add_element(kind, layer, "Current owner", None).unwrap();
let replacement = p
.add_element(kind, layer, "Reviewed replacement", None)
.unwrap();
let record = if child {
p.add_element(kind, layer, "Attached work", Some(owner))
.unwrap()
} else {
let peer = p.add_element(kind, layer, "Other endpoint", None).unwrap();
let (source, target) = if end == IncidentEnd::Source {
(owner, peer)
} else {
(peer, owner)
};
p.relate(
RelationKind::DataAssociation,
"Incident data association",
source,
target,
)
};
owners.push(owner);
attached.push(record);
outside.push(replacement);
}
for element in p.elements.values_mut() {
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
*definition = DataTypeDefinition::Record { supertype: None };
}
}
let trace = trace(&mut p, owners[0], owners[1], "Owner attachment rationale");
validate_candidate(&p).unwrap();
Fixture {
p,
owners: [owners[0], owners[1]],
attached: [attached[0], attached[1]],
outside: [outside[0], outside[1]],
trace,
child,
end,
}
}
fn selector(f: &Fixture) -> FacetSelector {
let keys = KeyPair {
oa: Some(f.attached[0]),
sa: Some(f.attached[1]),
};
if f.child {
FacetSelector::Child(keys)
} else {
FacetSelector::Incident { keys, end: f.end }
}
}
fn candidate(f: &Fixture, side: usize) -> SemanticRecord {
if f.child {
let mut value = f.p.elements[&f.attached[side]].clone();
value.parent = Some(f.outside[side]);
SemanticRecord::Element(value)
} else {
let mut value = f.p.relations[&f.attached[side]].clone();
let (a, b) = value.body.endpoints();
value.body.set_endpoints(
if f.end == IncidentEnd::Source {
f.outside[side]
} else {
a
},
if f.end == IncidentEnd::Target {
f.outside[side]
} else {
b
},
);
SemanticRecord::Relation(value)
}
}
fn request(f: &Fixture, reverse: bool, decision: SemanticDecision) -> SemanticTransitionRequest {
let source = usize::from(reverse);
let target = 1 - source;
let mut transition = TransitionRequest::new(
if reverse {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
},
vec![f.owners[source]],
);
transition.mappings.push(TransitionMapping {
source: f.owners[source],
target: Some(f.owners[target]),
target_kind: None,
});
let mut result = SemanticTransitionRequest::new(transition);
result.traces.insert(f.owners[source], f.trace);
result.references.insert(f.owners[0], f.owners[1]);
result.references.insert(f.attached[0], f.attached[1]);
result.reviews.push(SemanticReviewRequest {
source: f.owners[source],
resolutions: vec![FacetResolution {
facet: selector(f),
decision,
}],
reset: vec![],
});
result
}
fn prepared(p: &Project, request: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
let plan = plan_semantic_transition(p, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = prepare_semantic_transition(p, &plan).unwrap().project;
(result, plan)
}
#[test]
fn attachment_candidate_changes_existing_parent_or_endpoint_without_attached_record_trace() {
for child in [true, false] {
for end in [IncidentEnd::Source, IncidentEnd::Target] {
if child && end == IncidentEnd::Target {
continue;
}
for reverse in [false, true] {
let f = fixture(child, end);
assert_eq!(f.p.traces.len(), 1);
let side = usize::from(!reverse);
let expected = candidate(&f, side);
let mut request = request(&f, reverse, SemanticDecision::Candidate);
request.candidates.push(expected.clone());
let original_source = semantic_record(&f.p, f.attached[1 - side]);
let (result, plan) = prepared(&f.p, request);
assert_eq!(semantic_record(&result, f.attached[side]), Some(expected));
assert_eq!(
semantic_record(&result, f.attached[1 - side]),
original_source
);
assert_eq!(
result.traces.len(),
1,
"No relation/child trace is invented"
);
assert_eq!(plan.reviews[0].acknowledged, vec![selector(&f)]);
let source = language::format(&result);
assert_eq!(language::parse(&source).unwrap(), result);
let mut document = f.p.clone();
let receipt = apply_semantic_transition(&document, &plan).unwrap();
document = receipt;
assert_eq!(document, result);
}
}
}
}
#[test]
fn incident_absence_take_refuses_but_explicit_candidate_endpoint_is_reviewable() {
for end in [IncidentEnd::Source, IncidentEnd::Target] {
for reverse in [false, true] {
let mut f = fixture(false, end);
let source = usize::from(reverse);
let SemanticRecord::Relation(detached) = candidate(&f, source) else {
unreachable!()
};
f.p.relations.insert(f.attached[source], detached);
let take =
plan_semantic_transition(&f.p, request(&f, reverse, SemanticDecision::TakeSource))
.unwrap();
assert!(
take.conflicts
.iter()
.any(|e| e.contains("explicit final endpoint")),
"{:?}",
take.conflicts
);
let mut request = request(&f, reverse, SemanticDecision::Candidate);
request.candidates.push(candidate(&f, 1 - source));
let _ = prepared(&f.p, request);
}
}
}
#[test]
fn attachment_candidate_and_direct_record_keep_conflict_by_actual_slot_in_either_order() {
for child in [true, false] {
for end in [IncidentEnd::Source, IncidentEnd::Target] {
if child && end == IncidentEnd::Target {
continue;
}
for reverse in [false, true] {
let mut f = fixture(child, end);
let linked = trace(
&mut f.p,
f.attached[0],
f.attached[1],
"Independent record rationale",
);
let source = usize::from(reverse);
let target = 1 - source;
let mut request = request(&f, reverse, SemanticDecision::Candidate);
request.candidates.push(candidate(&f, target));
request.transition.sources.push(f.attached[source]);
request.transition.mappings.push(TransitionMapping {
source: f.attached[source],
target: Some(f.attached[target]),
target_kind: None,
});
request.traces.insert(f.attached[source], linked);
let facet = if child {
FacetSelector::Header(HeaderFacet::Parent)
} else {
FacetSelector::Relation(if end == IncidentEnd::Source {
RelationFacet::Source
} else {
RelationFacet::Target
})
};
request.reviews.push(SemanticReviewRequest {
source: f.attached[source],
resolutions: vec![FacetResolution {
facet,
decision: SemanticDecision::KeepTarget,
}],
reset: vec![],
});
for permute in [false, true] {
let mut request = request.clone();
if permute {
request.reviews.reverse();
request.transition.sources.reverse();
request.transition.mappings.reverse();
}
let plan = plan_semantic_transition(&f.p, request).unwrap();
assert!(
plan.conflicts.iter().any(|e| e.contains("Contradictory")),
"{child}/{end:?}/{reverse}: {:?}",
plan.conflicts
);
}
}
}
}
}
#[test]
fn convergent_owner_attachment_candidates_agree_and_keep_cannot_override_them() {
for child in [true, false] {
for end in [IncidentEnd::Source, IncidentEnd::Target] {
if child && end == IncidentEnd::Target {
continue;
}
let mut f = fixture(child, end);
let second_owner =
f.p.add_element(
f.p.elements[&f.owners[0]].kind(),
Layer::Oa,
"Second operational concern",
None,
)
.unwrap();
if let Some(SharedBody::DataType { definition, .. }) =
f.p.elements.get_mut(&second_owner).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record { supertype: None };
}
let second_record = Id::new_v4();
if child {
let mut value = f.p.elements[&f.attached[0]].clone();
value.id = second_record;
value.alias = format!("second_{}", second_record.simple());
value.parent = Some(second_owner);
f.p.elements.insert(second_record, value);
} else {
let mut value = f.p.relations[&f.attached[0]].clone();
value.id = second_record;
value.alias = format!("second_{}", second_record.simple());
let (a, b) = value.body.endpoints();
value.body.set_endpoints(
if end == IncidentEnd::Source {
second_owner
} else {
a
},
if end == IncidentEnd::Target {
second_owner
} else {
b
},
);
f.p.relations.insert(second_record, value);
}
let second_trace = trace(
&mut f.p,
second_owner,
f.owners[1],
"Second owner correspondence",
);
validate_candidate(&f.p).unwrap();
let mut request = request(&f, false, SemanticDecision::Candidate);
request.candidates.push(candidate(&f, 1));
request.transition.sources.push(second_owner);
request.transition.mappings.push(TransitionMapping {
source: second_owner,
target: Some(f.owners[1]),
target_kind: None,
});
request.references.insert(second_owner, f.owners[1]);
request.references.insert(second_record, f.attached[1]);
request.traces.insert(second_owner, second_trace);
let keys = KeyPair {
oa: Some(second_record),
sa: Some(f.attached[1]),
};
let facet = if child {
FacetSelector::Child(keys)
} else {
FacetSelector::Incident { keys, end }
};
request.reviews.push(SemanticReviewRequest {
source: second_owner,
resolutions: vec![FacetResolution {
facet,
decision: SemanticDecision::Candidate,
}],
reset: vec![],
});
let (agreed, _) = prepared(&f.p, request.clone());
assert_eq!(
semantic_record(&agreed, f.attached[1]),
Some(candidate(&f, 1))
);
for keep in [false, true] {
for permute in [false, true] {
let mut review = request.clone();
if keep {
review.reviews[1].resolutions[0].decision = SemanticDecision::KeepTarget;
}
if permute {
review.reviews.reverse();
review.transition.sources.reverse();
review.transition.mappings.reverse();
}
let plan = plan_semantic_transition(&f.p, review).unwrap();
if keep {
assert!(
plan.conflicts.iter().any(|e| e.contains("Contradictory")),
"{:?}",
plan.conflicts
);
} else {
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(
prepare_semantic_transition(&f.p, &plan).unwrap().project,
agreed
);
}
}
}
}
}
}
@@ -0,0 +1,410 @@
use oa_core::{source_project::*, transitions::*, view_options::*, *};
use std::collections::BTreeMap;
#[test]
fn source6_traceability_default_keeps_both_layers_and_indexed_projection_matches() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "OA send", None)
.unwrap();
let ob = p
.add_element(Kind::Activity, Layer::Oa, "OA receive", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "SA send", None)
.unwrap();
let sb = p
.add_element(Kind::Function, Layer::Sa, "SA receive", None)
.unwrap();
let interaction = p.relate(RelationKind::Interaction, "OA report", oa, ob);
let fe = Id::new_v4();
let request = FunctionalExchangeRequest {
id: fe,
alias: "sa_report".into(),
name: "SA report".into(),
description: String::new(),
source: sa,
target: sb,
source_port: PortChoice::Auto,
target_port: PortChoice::Auto,
items: vec![],
};
p = execute_batch(&p, &plan_functional_exchange(&p, &request).unwrap()).unwrap();
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
for (i, id) in [oa, ob, sa, sb].into_iter().enumerate() {
p.place(
d,
id,
Position {
x: 300. * i as f32,
y: 0.,
},
);
}
let index = ViewIndex::new(&p);
let view = projection::project_view_with_index(&p, d, &index);
assert!(view.edges.iter().any(|e| e.id == interaction));
assert!(view.edges.iter().any(|e| e.id == fe));
assert_eq!(view, projection::project_view(&p, d));
p.diagrams
.get_mut(&d)
.unwrap()
.view_options
.hidden_edges
.push(fe);
validate_candidate(&p).unwrap();
assert!(
!projection::project_view_with_index(&p, d, &index)
.edges
.iter()
.any(|e| e.id == fe)
);
assert!(
projection::project_view_with_index(&p, d, &index)
.edges
.iter()
.any(|e| e.id == interaction)
);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Overview;
assert!(validate_candidate(&p).is_err());
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
p.diagrams.get_mut(&d).unwrap().view_options.relation_kinds = RelationKinds::Only(vec![]);
assert!(
projection::project_view_with_index(&p, d, &index)
.edges
.is_empty()
);
}
#[test]
fn source6_edge_availability_uses_actual_shared_projection_fold_and_placement_rules() {
let mut p = Project::empty();
let parent = p
.add_element(Kind::Entity, Layer::Oa, "Team", None)
.unwrap();
let a = p
.add_element(Kind::Entity, Layer::Oa, "A", Some(parent))
.unwrap();
let b = p
.add_element(Kind::Entity, Layer::Oa, "B", Some(parent))
.unwrap();
let mean = p.relate(
RelationKind::CommunicationMean,
"Internal communication",
a,
b,
);
let d = p.ensure_diagram(Layer::Oa);
let outer = p.place(d, parent, Position { x: 0., y: 0. });
p.place(d, a, Position { x: 20., y: 100. });
let nb = p.place(d, b, Position { x: 350., y: 100. });
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::Visible
);
p.diagrams
.get_mut(&d)
.unwrap()
.view_options
.hidden_edges
.push(mean);
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::Visible
);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&outer)
.unwrap()
.collapsed = true;
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::FoldedInternal
);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&outer)
.unwrap()
.collapsed = false;
p.diagrams.get_mut(&d).unwrap().nodes.remove(&nb);
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::EndpointNotShown {
source: false,
target: true
}
);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::FunctionalDataflow;
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::OutsideProjection
);
}
fn setup() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p.add_element(Kind::Entity, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Actor, Layer::Oa, "B", None).unwrap();
let exchange = p.relate(RelationKind::CommunicationMean, "Communication", a, b);
let category = p
.add_element(
Kind::ExchangeCategory,
Layer::Oa,
"Participant exchanges",
None,
)
.unwrap();
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Participant,
exchanges: vec![exchange],
};
let diagram = p.ensure_diagram(Layer::Oa);
p.place(diagram, a, Position { x: 0., y: 0. });
p.place(diagram, b, Position { x: 400., y: 0. });
p.diagrams.get_mut(&diagram).unwrap().view_options = ViewOptions {
participant_categories: CategorySelection::Any(vec![category]),
hidden_edges: vec![exchange],
exchange_labels: ExchangeLabels::NameAndItems,
..Default::default()
};
(p, exchange, category, diagram, a)
}
#[test]
fn source6_views_round_trip_strict_fields_and_reference_aliases() {
let (p, _, category, _, _) = setup();
validate_candidate(&p).unwrap();
let source = language::format(&p);
assert!(source.contains("exchange_category"));
assert!(source.contains(&format!("@{}", p.elements[&category].alias)));
assert_eq!(language::parse(&source).unwrap(), p);
assert_eq!(
persistence::decode(&serde_json::to_vec(&p).unwrap()).unwrap(),
p
);
let bundle = SourceBundle {
entry: "model.arcadia".into(),
files: BTreeMap::from([("model.arcadia".into(), source)]),
};
assert_eq!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
p
);
}
#[test]
fn source6_view_defaults_cannot_be_smuggled_under_old_json_headers() {
let mut p = Project::empty();
p.ensure_diagram(Layer::Oa);
let current = serde_json::to_value(&p).unwrap();
for version in 3..=5 {
let mut old = current.clone();
old["schema_version"] = version.into();
for diagram in old["diagrams"].as_object_mut().unwrap().values_mut() {
diagram["view_options"] = serde_json::json!({});
}
let error = persistence::decode(&serde_json::to_vec(&old).unwrap())
.unwrap_err()
.to_string();
assert!(
error.contains("requires schema/source 6"),
"{version}: {error}"
);
for diagram in old["diagrams"].as_object_mut().unwrap().values_mut() {
diagram.as_object_mut().unwrap().remove("view_options");
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
p
);
}
}
#[test]
fn source6_category_and_saved_selection_reidentify_delete_preserve_empty_filters() {
let (p, exchange, category, diagram, a) = setup();
let replacement = Id::new_v4();
let q = execute_batch(&p, &[Command::Reidentify(exchange, replacement)]).unwrap();
assert!(
matches!(q.elements[&category].shared(),Some(SharedBody::ExchangeCategory{exchanges,..}) if exchanges==&[replacement])
);
assert_eq!(
q.diagrams[&diagram].view_options.hidden_edges,
vec![replacement]
);
let r = execute_batch(&q, &[Command::Delete(replacement)]).unwrap();
assert!(r.contains(category));
assert!(r.contains(a));
assert!(r.diagrams[&diagram].view_options.hidden_edges.is_empty());
assert!(
matches!(r.elements[&category].shared(),Some(SharedBody::ExchangeCategory{exchanges,..}) if exchanges.is_empty())
);
let s = execute_batch(&r, &[Command::Delete(category)]).unwrap();
assert_eq!(
s.diagrams[&diagram].view_options.participant_categories,
CategorySelection::Any(vec![])
);
}
#[test]
fn source6_category_transition_requires_mapped_members_and_reviews_exact_set() {
let (mut p, exchange, category, _, _) = setup();
let sa = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let sb = p
.add_element(Kind::SystemActor, Layer::Sa, "External", None)
.unwrap();
let port = |p: &mut Project, owner, name: &str| {
let id = Id::new_v4();
p.elements.insert(
id,
Element {
id,
alias: p.fresh_alias(name, "port", id),
name: name.into(),
description: String::new(),
parent: Some(owner),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
legacy: None,
},
),
},
);
id
};
let pa = port(&mut p, sa, "A endpoint");
let pb = port(&mut p, sb, "B endpoint");
let boundary = p.relate(RelationKind::BoundaryExchange, "Boundary", pa, pb);
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![category]);
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
request.mappings.push(TransitionMapping {
source: exchange,
target: Some(boundary),
target_kind: None,
});
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_transition(&p, &plan).unwrap();
let trace = q.traces.values().find(|t| t.oa == category).unwrap();
let target = trace.sa;
assert!(
matches!(q.elements[&target].shared(),Some(SharedBody::ExchangeCategory{family:ExchangeFamily::Participant,exchanges}) if exchanges==&[boundary])
);
let facets = semantic_facets(&q, trace, &BTreeMap::from([(exchange, boundary)])).unwrap();
assert!(
facets
.iter()
.any(|f| f.selector == FacetSelector::Category(CategoryFacet::Family))
);
assert!(facets.iter().any(|f| matches!(
f.selector,
FacetSelector::Member {
collection: ReferenceCollection::CategoryExchanges,
..
}
)));
}
#[test]
fn source6_new_historical_facts_are_rejected_under_source5_even_without_live_categories() {
let mut p = Project::empty();
let oa = p.add_element(Kind::Entity, Layer::Oa, "OA", None).unwrap();
let sa = p
.add_element(Kind::SystemActor, Layer::Sa, "SA", None)
.unwrap();
for fact in [
FacetReview::Category(CategoryReview::Family(Reviewed {
oa: Observed::NotApplicable,
sa: Observed::NotApplicable,
})),
FacetReview::Member {
collection: ReferenceCollection::CategoryExchanges,
keys: KeyPair {
oa: Some(Id::new_v4()),
sa: None,
},
review: Reviewed {
oa: Observed::NotApplicable,
sa: Observed::NotApplicable,
},
},
] {
let id = Id::new_v4();
p.traces.clear();
p.traces.insert(
id,
Trace {
id,
alias: "review".into(),
name: "History".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: Some(SemanticBaseline { facets: vec![fact] }),
}),
},
);
let mut json = serde_json::to_value(&p).unwrap();
json["schema_version"] = 5.into();
let error = persistence::decode(&serde_json::to_vec(&json).unwrap())
.unwrap_err()
.to_string();
assert!(
error.contains("category semantic facts require schema/source 6"),
"{error}"
);
let old =
language::format(&p).replacen(&format!("arcadia {SCHEMA_VERSION};"), "arcadia 5;", 1);
assert!(language::parse(&old).unwrap_err().iter().any(|d| {
d.message
.contains("category semantic facts require schema/source 6")
}));
}
}
#[test]
fn source6_projection_filters_keep_placements_and_label_modes_are_presentation_only() {
let (mut p, exchange, _, diagram, _) = setup();
let hidden = projection::project_view(&p, diagram);
assert!(hidden.edges.is_empty());
p.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.hidden_edges
.clear();
let shown = projection::project_view(&p, diagram);
assert_eq!(shown.edges.len(), 1);
assert_eq!(shown.edges[0].id, exchange);
assert_eq!(shown.nodes, hidden.nodes);
assert_eq!(shown.edges[0].source_port, None);
assert_eq!(shown.edges[0].target_port, None);
assert!(!shown.edges[0].oriented);
let route = routing::route_diagram(&p, &p.diagrams[&diagram], &shown);
assert!(route.edges[&exchange].label.text.contains("No items"));
p.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.relation_kinds = RelationKinds::Only(vec![]);
assert!(projection::project_view(&p, diagram).edges.is_empty());
}
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; }\n",
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; }\n",
)
.unwrap();
fs::create_dir(directory.path().join("parts")).unwrap();
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
fs::write(&entry, "arcadia 3; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(&entry, "arcadia 5; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(
directory.path().join("parts/a.arcadia"),
"oa_activity a \"A\" { invalid",
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
let overlays = BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 3; project p \"P\" { include \"new.arcadia\"; }".into(),
"arcadia 5; project p \"P\" { include \"new.arcadia\"; }".into(),
),
(
"parts/a.arcadia".into(),
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }".into(),
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }".into(),
),
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
]),
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
.unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
)
.unwrap();
assert!(matches!(
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
std::os::unix::fs::symlink(&destination, &link).unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"alias.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"alias.arcadia\"; }",
)
.unwrap();
let loaded = load_project(
@@ -6,10 +6,10 @@ fn source() -> CompiledSourceProject {
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 3; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("project.arcadia".into(), "arcadia 5; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
("contracts.arcadia".into(), "sa_interface contract \"Contract\" { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }\n".into()),
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
]),
},
ProjectLimits::default(),
@@ -91,8 +91,9 @@ fn identity_swap_and_reorder_keep_both_declarations_and_item_comments_with_origi
reidentify(&mut candidate, second, first).unwrap();
reidentify(&mut candidate, temporary, second).unwrap();
let contract = find(&candidate, "contract");
let Some(SharedBody::Interface { items }) =
candidate.elements.get_mut(&contract).unwrap().shared_mut()
let Some(SharedBody::Event {
body: EventBody::Signal { items },
}) = candidate.elements.get_mut(&contract).unwrap().shared_mut()
else {
panic!("interface")
};
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 3; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("project.arcadia".into(), "arcadia 5; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
]),
}, ProjectLimits::default()).unwrap()
+48 -34
View File
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
#[test]
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
let input = bundle(
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
@@ -95,7 +95,7 @@ diagram review "Review" {
#[test]
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
let mut input = bundle(
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
fn typed() -> SourceBundle {
bundle(
"arcadia 3; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
"arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
&[
(
"exchanges.arcadia",
@@ -157,7 +157,19 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
assert_eq!(again.decoded.project, result.decoded.project);
assert_eq!(again.bundle, result.bundle);
assert_eq!(again.index, result.index);
assert!(result.decoded.migration.is_none());
let migration = result.decoded.migration.as_ref().unwrap();
assert_eq!(
(migration.from_schema, migration.to_schema),
(5, oa_core::SCHEMA_VERSION)
);
assert!(input.files[&input.entry].contains("arcadia 5;"));
assert!(
result.bundle.files[&result.bundle.entry].contains(&format!("arcadia {SCHEMA_VERSION};"))
);
assert!(
again.decoded.migration.is_none(),
"stabilized current source must not migrate twice"
);
let project = &result.decoded.project;
assert_eq!(
project.relations[&id(project, "message")].source(),
@@ -194,7 +206,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
#[test]
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
@@ -228,7 +240,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
#[test]
fn source_errors_keep_exact_fragment_unicode_positions() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
&[(
"child.arcadia",
"// Пример\nsa_function child \"航海\" { parent @missing; }",
@@ -274,7 +286,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
),
] {
let input = bundle(
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[("a.arcadia", left), ("b.arcadia", right)],
);
let error = compile_bundle(&input, ProjectLimits::default())
@@ -285,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
assert_eq!(error.related[0].path, "a.arcadia");
}
let input = bundle(
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
);
let error = compile_bundle(&input, ProjectLimits::default())
@@ -297,7 +309,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
#[test]
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
let mut input = bundle(
"arcadia 3; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
&[
("sub/a.arcadia", "include \"../shared.arcadia\";"),
("sub/b.arcadia", "include \"../shared.arcadia\";"),
@@ -356,7 +368,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
);
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
let input = bundle(
"arcadia 3; project p \"P\" {}",
"arcadia 5; project p \"P\" {}",
&[("A.arcadia", ""), ("a.arcadia", "")],
);
assert_eq!(
@@ -370,7 +382,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
#[test]
fn project_admission_is_aggregate_and_precedes_global_resolution() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
("b.arcadia", "oa_activity two \"Two\" {}"),
@@ -591,7 +603,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
#[test]
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
let source = "arcadia 3; project p \"P\" { include \"missing.arcadia\"; }";
let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }";
assert!(
language::parse(source).unwrap_err()[0]
.message
@@ -681,7 +693,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
#[test]
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
let input = bundle(
"arcadia 3;project p\"P\"{include \"child.arcadia\";}",
"arcadia 5;project p\"P\"{include \"child.arcadia\";}",
&[(
"child.arcadia",
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
@@ -703,7 +715,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
#[test]
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
&[("a.arcadia", "")],
);
assert_eq!(
@@ -720,7 +732,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
"project_limit"
);
let input = bundle(
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[
(
"a.arcadia",
@@ -763,7 +775,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
&source[span.start..span.end],
"include \"../shared.arcadia\";"
);
let entry = "arcadia 3; project p \"P\" { include \"sub/work.arcadia\"; }";
let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }";
assert_eq!(
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
.target,
@@ -829,7 +841,7 @@ fn comments(source: &str) -> Vec<String> {
#[test]
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
let entry = "arcadia 3;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
let mut project = current.decoded.project.clone();
@@ -864,7 +876,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
#[test]
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
@@ -887,7 +899,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
#[test]
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
@@ -913,7 +925,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
#[test]
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"item.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"item.arcadia\"; }",
&[(
"item.arcadia",
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
@@ -953,7 +965,7 @@ fn significant_words(source: &str) -> Vec<String> {
#[test]
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"event.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"event.arcadia\"; }",
&[(
"event.arcadia",
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
@@ -986,7 +998,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
#[test]
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"ports.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"ports.arcadia\"; }",
&[(
"ports.arcadia",
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
@@ -1017,10 +1029,10 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
#[test]
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }\n",
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
)],
);
let current = compile(&input);
@@ -1029,8 +1041,9 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
let b = id(&project, "b");
let service = id(&project, "service");
project.elements.get_mut(&a).unwrap().alias = "renamed_a".into();
if let Some(SharedBody::Interface { items }) =
project.elements.get_mut(&service).unwrap().shared_mut()
if let Some(SharedBody::Event {
body: EventBody::Signal { items },
}) = project.elements.get_mut(&service).unwrap().shared_mut()
{
*items = vec![b, a];
} else {
@@ -1051,10 +1064,10 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
#[test]
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }\n",
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
)],
);
let current = compile(&input);
@@ -1063,8 +1076,9 @@ fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations()
let b = id(&project, "b");
let service = id(&project, "service");
project.elements.remove(&a);
if let Some(SharedBody::Interface { items }) =
project.elements.get_mut(&service).unwrap().shared_mut()
if let Some(SharedBody::Event {
body: EventBody::Signal { items },
}) = project.elements.get_mut(&service).unwrap().shared_mut()
{
*items = vec![b];
} else {
@@ -1102,7 +1116,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
}; }; }
"#;
let current = compile(&bundle(
"arcadia 3; project p \"P\" { include \"conversation.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }",
&[("conversation.arcadia", source)],
));
let mut project = current.decoded.project.clone();
@@ -1151,7 +1165,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
#[test]
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
let input = bundle(
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
@@ -1160,7 +1174,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
let empty = compile(&input);
let mut project = empty.decoded.project.clone();
let values = id(&project, "values");
if let Some(SharedBody::DataType { definition }) =
if let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&values).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Enumeration {
@@ -1185,7 +1199,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
.replace("\"Beta\"", "\"Beta\" // beta explanation\n"),
);
let annotated = compile(&annotated);
if let Some(SharedBody::DataType { definition }) =
if let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&values).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Enumeration {
+1
View File
@@ -18,6 +18,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
+48 -9
View File
@@ -42,6 +42,7 @@ fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
};
p.traces.insert(id, t);
@@ -328,11 +329,12 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
let port = |p: &mut Project, name: &str, parent, direction| {
let id = Id::new_v4();
let contract = if p.elements[&parent].kind() == Kind::Function {
@@ -740,6 +742,9 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
minimum: 0,
maximum: None,
unit: "items".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
@@ -804,8 +809,40 @@ fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references()
"Reidentification must preserve all values and order"
);
if id != p.id {
let deleted = execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
let deleted = if p
.relations
.get(&id)
.is_some_and(|r| r.kind() == RelationKind::InterfaceItemAllocation)
{
// An explicitly empty interface cannot silently accept the
// still-carried payload. Removing its definition needs a
// reviewed edit of that dependent contract in the same batch.
let mut session = Session::new(p.clone());
assert!(
session
.execute(Command::Delete(id))
.unwrap_err()
.0
.contains("port_interface_items")
);
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), 0);
let item = p.relations[&id].target();
let mut commands = vec![Command::Delete(id)];
commands.extend(
p.relations
.values()
.filter(|r| {
r.kind() == RelationKind::BoundaryExchange
&& r.body.items().contains(&item)
})
.map(|r| Command::Delete(r.id)),
);
execute_batch(&p, &commands).unwrap()
} else {
execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"))
};
accepted(&deleted);
}
}
@@ -890,13 +927,15 @@ fn demo_has_both_layers_complete_contracts_and_no_checks() {
assert!(p.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material
kind: ExchangeItemKind::Material,
..
})
)));
assert!(p.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy
kind: ExchangeItemKind::Energy,
..
})
)));
}
+9
View File
@@ -13,5 +13,14 @@ path = "src/main.rs"
oa-core = { path = "../oa-core" }
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
rfd = "0.15"
serde.workspace = true
serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
[dev-dependencies]
iced_runtime = "0.14"
oa-server = { path = "../oa-server" }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
+1
View File
@@ -0,0 +1 @@
include!("../../build/windows_manifest.rs");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,718 @@
//! Named, view-local creation. A draft never enters the accepted model.
use super::*;
use iced::widget::{button, column, pick_list, row, scrollable, text, text_input};
use serde::{Deserialize, Serialize};
mod palette;
#[cfg(test)]
mod tests;
pub use palette::view as palette;
#[derive(Debug, Clone)]
pub enum Event {
Start(Kind, Option<Position>),
Allocate(Kind, Id, Option<Position>),
Rename(crate::route_edit::Context, Id, Position),
TogglePalette,
Existing(Option<Position>),
Name(String),
Submit,
Click(crate::route_edit::Context, Position),
Pick(Id),
Locate(Id),
Search(String),
Filter(Filter),
ShowPlaced(bool),
Page(usize),
CapturedEscape,
Cancel,
}
pub fn subscription(app: &App) -> Subscription<Message> {
if app.architecture.as_ref().is_some_and(State::inline) {
iced::event::listen_with(captured_escape)
} else {
Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<Message> {
// A focused Iced text input captures Escape while dropping its focus. Only
// inline architecture naming opts into also cancelling that same keypress.
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(Message::Architecture(Event::CapturedEscape))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Filter {
Context,
AllCompatible,
Kind(Kind),
}
impl std::fmt::Display for Filter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Context => f.write_str("Suggested for this view"),
Self::AllCompatible => f.write_str("All compatible types"),
Self::Kind(k) => f.write_str(k.label()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Intent {
Create {
kind: Kind,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
performer: Option<Id>,
},
Rename {
element: Id,
name: String,
},
Existing {
selected: Option<Id>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Saved {
pub version: u32,
pub base: String,
pub diagram: Id,
pub intent: Intent,
pub point: Option<Position>,
pub placing: bool,
pub query: String,
pub filter: Filter,
pub show_placed: bool,
pub page: usize,
}
#[derive(Debug, Clone)]
pub struct State {
pub saved: Saved,
document: Id,
revision: u64,
pub error: Option<String>,
}
impl State {
pub fn restore(app: &App, saved: Saved) -> Result<Self, String> {
validate_saved(app.project(), &saved)?;
Ok(Self {
saved,
document: app.canvas_document,
revision: app.document.revision(),
error: None,
})
}
pub fn modal(&self) -> bool {
!self.saved.placing && matches!(self.saved.intent, Intent::Existing { .. })
}
pub fn inline(&self) -> bool {
!self.saved.placing
&& self.saved.point.is_some()
&& !matches!(self.saved.intent, Intent::Existing { .. })
}
pub fn unnamed_tool(&self) -> bool {
self.saved.placing
&& self.saved.point.is_none()
&& matches!(&self.saved.intent, Intent::Create { name, .. } if name.is_empty())
}
}
pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
if s.version != 1 || s.base != oa_core::transitions::project_fingerprint(p) {
return Err("The architecture draft does not match its accepted model.".into());
}
let d = p
.diagrams
.get(&s.diagram)
.ok_or("The creation draft's diagram is missing.")?;
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|| s.query.len() > 16384
|| s.page > 1_000_000
|| s.point
.is_some_and(|p| !p.x.is_finite() || !p.y.is_finite())
{
return Err("Invalid architecture creation draft context.".into());
}
match &s.intent {
Intent::Create {
kind,
name,
performer,
} if name.len() > 16384
|| !kind.supports(d.layer)
|| !d.projection.includes(*kind)
|| *kind == Kind::Port
|| performer.is_some_and(|id| {
!kind.is_work()
|| !p
.elements
.get(&id)
.is_some_and(|e| e.kind().is_performer() && e.layer() == d.layer)
}) =>
{
Err("Invalid architecture creation draft type or name.".into())
}
Intent::Existing { selected: Some(id) } if !compatible(p, d, *id) => {
Err("The creation draft's existing element is incompatible.".into())
}
Intent::Rename { element, name }
if name.len() > 16384
|| !p.elements.contains_key(element)
|| !d.nodes.values().any(|n| n.element == *element) =>
{
Err("The rename draft's element is not shown in this view.".into())
}
_ => Ok(()),
}
}
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
let mut values = match (d.layer, d.projection) {
(_, ProjectionKind::StateMachine { .. }) => vec![],
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
(Layer::Oa, ProjectionKind::Capabilities) => vec![
Kind::OperationalMission,
Kind::Capability,
Kind::Entity,
Kind::Actor,
],
(Layer::Sa, ProjectionKind::Capabilities) => vec![
Kind::Mission,
Kind::SystemCapability,
Kind::System,
Kind::SystemActor,
],
(Layer::Oa, _) => vec![Kind::Entity, Kind::Actor, Kind::Activity],
(Layer::Sa, _) => vec![Kind::System, Kind::SystemActor, Kind::Function],
};
values.retain(|k| *k != Kind::System || !p.elements.values().any(|e| e.kind() == Kind::System));
values
}
pub fn kinds(app: &App) -> Vec<Kind> {
app.active_diagram
.and_then(|id| app.project().diagrams.get(&id))
.map(|d| context_kinds(app.project(), d))
.unwrap_or_default()
}
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
}
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
// Existing System remains a useful choice even when the singleton exists.
context_kinds(p, d).contains(&kind)
|| (kind == Kind::System
&& d.layer == Layer::Sa
&& d.projection != ProjectionKind::FunctionalDataflow)
}
pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
let Some(s) = &app.architecture else {
return vec![];
};
let p = app.project();
let Some(d) = p.diagrams.get(&s.saved.diagram) else {
return vec![];
};
let query = s.saved.query.to_lowercase();
let mut entries: Vec<_> = p
.elements
.values()
.filter(|e| compatible(p, d, e.id))
.filter(|e| match s.saved.filter {
Filter::Context => suggested(p, d, e.kind()),
Filter::AllCompatible => true,
Filter::Kind(k) => e.kind() == k,
})
.filter_map(|e| {
let shown = d.nodes.values().any(|n| n.element == e.id);
let label = format!(
"{} · @{} · {} · {}",
e.name,
e.alias,
e.kind().label(),
e.parent.map(|id| p.named(id)).unwrap_or("At root")
);
((!shown || s.saved.show_placed) && label.to_lowercase().contains(&query))
.then_some((e.id, label, shown))
})
.collect();
entries.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
entries
}
impl App {
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
if self.architecture.is_some() {
if self.architecture.as_ref().is_some_and(State::unnamed_tool) {
self.architecture = None;
} else {
self.status =
"Finish or cancel the current name before choosing another tool."
.into();
return Task::none();
}
}
if self.page != Page::Diagram
|| self.source_dirty()
|| self.draft_dirty()
|| self.behavior.has_pending()
|| self.workflow.is_some()
{
self.status =
"Apply or cancel pending edits before creating diagram content.".into();
return Task::none();
}
let Some(diagram) = self.active_diagram else {
return Task::none();
};
let intent = match event {
Event::Start(kind, _) if kinds(self).contains(&kind) => Intent::Create {
kind,
name: String::new(),
performer: None,
},
Event::Allocate(kind, performer, _)
if kinds(self).contains(&kind) && kind.is_work() =>
{
Intent::Create {
kind,
name: String::new(),
performer: Some(performer),
}
}
Event::Existing(_) => Intent::Existing { selected: None },
_ => return Task::none(),
};
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
diagram,
intent,
point,
placing,
query: String::new(),
filter: Filter::Context,
show_placed: false,
page: 0,
};
if let Ok(state) = State::restore(self, saved) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.architecture = Some(state);
self.connection = None;
self.fit_to_view = false;
self.status =
"Click in the diagram to position the new element · Esc cancels".into();
}
return iced::widget::operation::focus("architecture-name");
}
Event::TogglePalette => {
self.architecture_palette_expanded = !self.architecture_palette_expanded;
self.fit_to_view = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::Rename(context, element, point) => {
if self.architecture.is_some()
|| self.route_context() != Some(context)
|| self.page != Page::Diagram
|| self.draft_dirty()
|| self.source_dirty()
|| self.behavior.has_pending()
{
return Task::none();
}
let Some(e) = self.project().elements.get(&element) else {
return Task::none();
};
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
diagram: context.diagram,
intent: Intent::Rename {
element,
name: e.name.clone(),
},
point: Some(point),
placing: false,
query: String::new(),
filter: Filter::Context,
show_placed: false,
page: 0,
};
if let Ok(state) = State::restore(self, saved) {
self.architecture = Some(state);
self.connection = None;
self.fit_to_view = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return iced::widget::operation::focus("architecture-name");
}
Event::CapturedEscape => {
if self.architecture.as_ref().is_some_and(State::inline) {
return self.architecture_event(Event::Cancel);
}
}
Event::Cancel => {
self.architecture = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Cancelled · Select / move".into();
}
Event::Name(value) => {
if value.len() <= 16384
&& let Some(s) = &mut self.architecture
&& let Intent::Create { name, .. } | Intent::Rename { name, .. } =
&mut s.saved.intent
{
*name = value;
s.error = None;
}
}
Event::Search(query) => {
if query.len() <= 16384
&& let Some(s) = &mut self.architecture
{
s.saved.query = query;
s.saved.page = 0;
}
}
Event::Filter(filter) => {
if let Some(s) = &mut self.architecture {
s.saved.filter = filter;
s.saved.page = 0;
}
}
Event::ShowPlaced(value) => {
if let Some(s) = &mut self.architecture {
s.saved.show_placed = value;
s.saved.page = 0;
}
}
Event::Page(page) => {
if let Some(s) = &mut self.architecture {
s.saved.page = page.min(1_000_000);
}
}
Event::Pick(id) => {
if !candidates(self)
.iter()
.any(|(candidate, _, shown)| *candidate == id && !shown)
{
return Task::none();
}
if let Some(s) = &mut self.architecture {
s.saved.intent = Intent::Existing { selected: Some(id) };
}
return self.architecture_event(Event::Submit);
}
Event::Locate(id) => {
if candidates(self)
.iter()
.any(|(candidate, _, shown)| *candidate == id && *shown)
&& let Some(s) = self.architecture.take()
{
return self.update(Message::RevealInDiagram(s.saved.diagram, id));
}
}
Event::Submit => {
let Some(s) = &mut self.architecture else {
return Task::none();
};
if matches!(&s.saved.intent, Intent::Create { name, .. } | Intent::Rename { name, .. } if name.trim().is_empty())
{
s.error = Some("Enter an engineering name before placing this element.".into());
return Task::none();
}
if matches!(s.saved.intent, Intent::Existing { selected: None }) {
return Task::none();
}
s.saved.placing = true;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(point) = s.saved.point {
self.commit_architecture(point);
}
}
Event::Click(context, point) => {
if self.route_context() != Some(context)
|| !point.x.is_finite()
|| !point.y.is_finite()
{
return Task::none();
}
if self.architecture.as_ref().is_some_and(|s| s.saved.placing) {
if matches!(
self.architecture.as_ref().unwrap().saved.intent,
Intent::Existing { .. }
) {
self.commit_architecture(point);
} else {
let s = self.architecture.as_mut().unwrap();
s.saved.point = Some(point);
s.saved.placing = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return iced::widget::operation::focus("architecture-name");
}
}
}
}
Task::none()
}
fn commit_architecture(&mut self, point: Position) {
let Some(s) = self.architecture.as_ref() else {
return;
};
if s.document != self.canvas_document
|| s.revision != self.document.revision()
|| Some(s.saved.diagram) != self.active_diagram
|| self.page != Page::Diagram
|| self.source_dirty()
|| self.draft_dirty()
|| self.behavior.has_pending()
|| !point.x.is_finite()
|| !point.y.is_finite()
{
self.status =
"The diagram changed. The name is retained; cancel and restart this placement."
.into();
if let Some(s) = &mut self.architecture {
s.saved.placing = false;
s.error = Some(self.status.clone());
}
return;
}
let diagram = s.saved.diagram;
if let Intent::Rename { element, name } = &s.saved.intent {
let element = *element;
let name = name.clone();
if name.trim().is_empty() {
return;
}
if self.execute(Command::Rename(element, name)) {
self.architecture = None;
self.selected = element;
self.refresh_draft();
self.status = "Name updated · Undo restores the previous name".into();
} else if let Some(s) = &mut self.architecture {
s.error = Some(self.status.clone());
s.saved.placing = false;
}
return;
}
let mut candidate = self.project().clone();
let mut commands = vec![];
let id = match &s.saved.intent {
Intent::Create {
kind,
name,
performer,
} => {
if name.trim().is_empty() || !kinds(self).contains(kind) {
return;
}
let Some(element) =
candidate.make_element(*kind, candidate.diagrams[&diagram].layer, name, None)
else {
return;
};
let id = element.id;
candidate.elements.insert(id, element.clone());
commands.push(Command::CreateElement(element));
if let Some(performer) = performer {
commands.push(Command::Connect(
RelationKind::Allocation,
"Performs".into(),
*performer,
id,
));
}
id
}
Intent::Existing { selected: Some(id) }
if compatible(&candidate, &candidate.diagrams[&diagram], *id)
&& !candidate.diagrams[&diagram]
.nodes
.values()
.any(|n| n.element == *id) =>
{
*id
}
_ => return,
};
let node = candidate.place(diagram, id, point);
commands.push(Command::CreateNode {
diagram,
node: candidate.diagrams[&diagram].nodes[&node].clone(),
});
if self.execute(Command::Batch(commands)) {
self.architecture = None;
self.selected = id;
self.selected_node = Some(node);
self.selected_bend = None;
self.inspector_tab = crate::navigation::InspectorTab::Properties;
self.refresh_draft();
self.fit_to_view = false;
self.status = "Element placed · Undo restores the whole change. Drag to move; Fit is available when needed.".into();
} else if let Some(s) = &mut self.architecture {
s.error = Some(self.status.clone());
s.saved.placing = false;
}
}
}
pub fn inline_editor(app: &App) -> Option<Element<'_, Message>> {
let s = app.architecture.as_ref().filter(|s| s.inline())?;
let point = s.saved.point?;
let size = app.canvas_size.unwrap_or(iced::Size::new(450., 350.));
let width = (size.width - 16.).clamp(120., 320.);
let x = (point.x * app.zoom + app.pan.x).clamp(8., (size.width - width - 8.).max(8.));
let y = (point.y * app.zoom + app.pan.y).clamp(8., (size.height - 152.).max(8.));
Some(
column![
iced::widget::Space::new().height(y),
row![
iced::widget::Space::new().width(x),
iced::widget::opaque(
iced::widget::container(scrollable(name_editor(app)))
.padding(10)
.width(width)
.max_height((size.height - y - 8.).max(0.))
.style(crate::style::card)
)
]
]
.into(),
)
}
fn name_editor(app: &App) -> Element<'_, Message> {
let s = app.architecture.as_ref().unwrap();
let (title, name, action) = match &s.saved.intent {
Intent::Create {
kind,
name,
performer,
} => (
performer
.map(|id| {
format!(
"{} · performed by {}",
kind.label(),
crate::views::short(app.project().named(id), 32)
)
})
.unwrap_or_else(|| format!("New {}", kind.label())),
name,
"Create",
),
Intent::Rename { name, .. } => ("Rename".into(), name, "Apply"),
Intent::Existing { .. } => unreachable!(),
};
let mut content = column![
text(title).size(12),
text_input("Engineering name", name)
.id("architecture-name")
.on_input(|v| Message::Architecture(Event::Name(v)))
.on_submit(Message::Architecture(Event::Submit))
.padding(7)
.size(13),
row![
button(text(action).size(12))
.padding([5, 9])
.style(crate::style::nav)
.on_press(Message::Architecture(Event::Submit)),
button(text("Cancel").size(12))
.padding([5, 9])
.style(crate::style::nav)
.on_press(Message::Architecture(Event::Cancel))
]
.spacing(6)
]
.spacing(7);
if let Some(error) = &s.error {
content = content.push(text(crate::views::short(error, 100)).size(11));
}
content.into()
}
pub fn dialog(app: &App) -> Element<'_, Message> {
let s = app.architecture.as_ref().unwrap();
let action = |label: String, event| {
button(text(label).size(12))
.padding(8)
.on_press(Message::Architecture(event))
};
let mut content = column![].spacing(12);
match &s.saved.intent {
Intent::Create { .. } | Intent::Rename { .. } => return name_editor(app),
Intent::Existing { .. } => {
let p = app.project();
let d = &p.diagrams[&s.saved.diagram];
let mut filters = vec![Filter::Context, Filter::AllCompatible];
filters.extend(
Kind::ALL
.into_iter()
.filter(|k| {
*k != Kind::Port
&& d.projection.includes(*k)
&& (k.supports(d.layer) || d.projection == ProjectionKind::Traceability)
})
.map(Filter::Kind),
);
let entries = candidates(app);
let page = s.saved.page.min(entries.len().saturating_sub(1) / 30);
let mut rows = column![].spacing(5);
for (id, label, shown) in entries.iter().skip(page * 30).take(30) {
rows = rows.push(
action(
format!("{}{}", if *shown { "Locate · " } else { "Add · " }, label),
if *shown {
Event::Locate(*id)
} else {
Event::Pick(*id)
},
)
.width(iced::Fill),
);
}
content = content.push(text("Add existing to this diagram").size(22))
.push(text("Add a model element to this view. Its name and properties stay shared across views.").size(13))
.push(text_input("Optional name, alias or owner filter", &s.saved.query).id("architecture-name").on_input(|v| Message::Architecture(Event::Search(v))).padding(9))
.push(pick_list(filters, Some(s.saved.filter), |f| Message::Architecture(Event::Filter(f))))
.push(iced::widget::checkbox(s.saved.show_placed).label("Include already shown (Locate)").on_toggle(|v| Message::Architecture(Event::ShowPlaced(v))))
.push(text(format!("{} compatible matches · page {}", entries.len(), page + 1)).size(12));
if entries.is_empty() {
content = content.push(text("No unplaced matches. Try All compatible types or include already shown elements.").size(13));
}
content = content
.push(scrollable(rows).height((app.window_size.height - 500.).clamp(120., 260.)))
.push(
row![
action("Previous".into(), Event::Page(page.saturating_sub(1))),
action(
"Next".into(),
Event::Page((page + 1).min(entries.len().saturating_sub(1) / 30))
),
action("Cancel".into(), Event::Cancel)
]
.spacing(8),
);
}
}
if let Some(error) = &s.error {
content = content.push(text(error).size(13));
}
content.into()
}
@@ -0,0 +1,268 @@
//! Compact neutral tool palette. Tool state never changes its allocated width.
use super::*;
use iced::{
Point, Rectangle, Size, Theme, mouse,
widget::{
canvas::{self, Frame, Geometry, Path, Stroke},
container, tooltip,
},
};
#[derive(Clone, Copy)]
enum Icon {
Element(Kind),
Arrow,
Existing,
Select,
Collapse(bool),
}
impl canvas::Program<Message> for Icon {
type State = ();
fn draw(
&self,
_: &(),
renderer: &iced::Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
) -> Vec<Geometry> {
let mut f = Frame::new(renderer, bounds.size());
let color = match self {
Self::Element(k) => crate::style::kind_color(*k),
_ => crate::style::INK,
};
let stroke = Stroke::default().with_color(color).with_width(1.4);
let line = |points: &[(f32, f32)]| {
Path::new(|p| {
p.move_to(Point::new(points[0].0, points[0].1));
for &(x, y) in &points[1..] {
p.line_to(Point::new(x, y));
}
})
};
match self {
Self::Element(Kind::Actor | Kind::SystemActor | Kind::Role) => {
f.stroke(&Path::circle(Point::new(8., 4.), 2.5), stroke);
for points in [
&[(8., 6.5), (8., 11.)][..],
&[(3., 8.), (13., 8.)],
&[(4., 15.), (8., 11.), (12., 15.)],
] {
f.stroke(&line(points), stroke);
}
}
Self::Element(Kind::Capability | Kind::SystemCapability) => {
f.stroke(&Path::circle(Point::new(8., 8.), 6.), stroke);
}
Self::Element(Kind::Mission | Kind::OperationalMission) => {
f.stroke(
&line(&[(3., 15.), (3., 2.), (13., 2.), (10., 6.), (3., 6.)]),
stroke,
);
}
Self::Element(k) => {
f.stroke(
&Path::rounded_rectangle(
Point::new(1., 3.),
Size::new(14., 10.),
if k.is_work() { 3.into() } else { 0.into() },
),
stroke,
);
if !k.is_work() {
f.stroke(
&Path::rectangle(Point::new(4., 6.), Size::new(8., 4.)),
stroke,
);
}
}
Self::Arrow => {
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
}
Self::Existing => {
f.stroke(
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
stroke,
);
f.stroke(
&Path::rectangle(Point::new(5., 5.), Size::new(10., 10.)),
stroke,
);
}
Self::Select => {
f.stroke(
&line(&[
(3., 1.),
(3., 14.),
(7., 10.),
(11., 15.),
(13., 14.),
(9., 9.),
(15., 8.),
(3., 1.),
]),
stroke,
);
}
Self::Collapse(expanded) => {
let (a, b) = if *expanded { (5., 11.) } else { (11., 5.) };
f.stroke(&line(&[(a, 3.), (b, 8.), (a, 13.)]), stroke);
}
}
vec![f.into_geometry()]
}
}
fn tool<'a>(
app: &App,
icon: Icon,
label: &'a str,
message: Message,
selected: bool,
) -> Element<'a, Message> {
let width = if app.architecture_palette_expanded {
156.
} else {
36.
};
let graphic = iced::widget::canvas(icon).width(16).height(16);
let mut content = row![graphic].spacing(7).align_y(iced::Center);
if app.architecture_palette_expanded {
content = content.push(text(label).size(11).wrapping(text::Wrapping::None));
}
let mut enabled = app.architecture.as_ref().is_none_or(State::unnamed_tool)
|| matches!(
&message,
Message::Architecture(Event::Cancel | Event::TogglePalette)
);
let mut hint = label.to_owned();
if let Message::Connection(crate::connection_edit::Event::Choose(Some(connection))) = &message {
let view = oa_core::projection::project_view(app.project(), app.active_diagram.unwrap());
if !crate::connection_edit::visible(&view).any(|id| connection.can_start(app.project(), id))
{
enabled = false;
hint.push_str(" — add or show a compatible source first");
}
}
let control = button(content)
.padding([5, 7])
.height(28)
.width(width - 4.)
.clip(true)
.style(if selected {
crate::style::selected
} else {
crate::style::nav
})
.on_press_maybe(enabled.then_some(message));
tooltip(
control,
container(text(hint).size(12))
.padding(7)
.style(crate::style::card),
tooltip::Position::Left,
)
.into()
}
pub fn view(app: &App) -> Element<'_, Message> {
let mut content = iced::widget::column![tool(
app,
Icon::Collapse(app.architecture_palette_expanded),
if app.architecture_palette_expanded {
"Collapse tools"
} else {
"Expand tools"
},
Message::Architecture(Event::TogglePalette),
false
)]
.spacing(3);
let group = |name: &'static str| -> Element<'_, Message> {
if app.architecture_palette_expanded {
container(text(name).size(10).color(crate::style::MUTED))
.padding([5, 4])
.into()
} else {
iced::widget::rule::horizontal(1).into()
}
};
content = content.push(tool(
app,
Icon::Select,
if app.architecture.is_some() {
"Cancel / select"
} else {
"Select / move"
},
if app.architecture.is_some() {
Message::Architecture(Event::Cancel)
} else {
Message::Connection(crate::connection_edit::Event::Choose(None))
},
app.architecture.is_none() && app.connection.is_none(),
));
for (heading, work) in [
("Participants / capabilities", false),
("Activities / functions", true),
] {
let choices: Vec<_> = kinds(app)
.into_iter()
.filter(|k| k.is_work() == work)
.collect();
if !choices.is_empty() {
content = content.push(group(heading));
}
for kind in choices {
let active = app
.architecture
.as_ref()
.is_some_and(|s| matches!(s.saved.intent,Intent::Create{kind:k,..} if kind==k));
content = content.push(tool(
app,
Icon::Element(kind),
kind.label(),
Message::Architecture(Event::Start(kind, None)),
active,
));
}
}
content = content.push(group("Connections"));
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
let kind = match connection {
crate::connection_tool::Tool::Interaction => Kind::Activity,
crate::connection_tool::Tool::FunctionalExchange => Kind::Function,
crate::connection_tool::Tool::CommunicationMean => Kind::Entity,
crate::connection_tool::Tool::BoundaryExchange => Kind::System,
};
if !d.projection.includes(kind) {
continue;
}
content = content.push(tool(
app,
Icon::Arrow,
connection.label(),
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
app.connection
.as_ref()
.is_some_and(|s| s.tool == *connection),
));
}
content = content.push(group("Common")).push(tool(
app,
Icon::Existing,
"Add existing…",
Message::Architecture(Event::Existing(None)),
false,
));
container(scrollable(content))
.width(if app.architecture_palette_expanded {
156
} else {
36
})
.height(iced::Fill)
.padding(2)
.style(crate::style::panel)
.into()
}
@@ -0,0 +1,378 @@
use super::*;
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
app.layer = layer;
app.active_diagram = None;
let _ = app.update(Message::AddDiagram);
app.zoom = 0.75;
app.pan = iced::Vector::new(37., -19.);
app
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Architecture(event));
}
fn create(app: &mut App, kind: Kind, name: &str) -> Id {
send(app, Event::Start(kind, Some(Position { x: 120., y: 160. })));
send(app, Event::Name(name.into()));
send(app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
app.selected
}
#[test]
fn architecture_allocated_work_is_root_owned_and_one_exact_history_change() {
for (layer, participant, work) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
let mut app = setup(layer);
let performer = create(&mut app, participant, "Control team");
let before = app.document.bundle().clone();
let camera = (app.zoom, app.pan);
let point = Position { x: 151., y: 221. };
send(&mut app, Event::Allocate(work, performer, Some(point)));
send(&mut app, Event::Name(" Report 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
let created = app.selected;
let element = &app.project().elements[&created];
assert_eq!(element.kind(), work);
assert_eq!(element.name, " Report 雨 ");
assert_eq!(element.parent, None);
assert!(app.project().relations.values().any(|r| {
r.kind() == RelationKind::Allocation && r.source() == performer && r.target() == created
}));
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
assert_eq!(d.nodes[&app.selected_node.unwrap()].position, point);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
}
#[test]
fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
use iced::widget::canvas::{Event as CanvasEvent, Program};
use iced::{keyboard, mouse};
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Entity, "Original team");
let before = app.document.bundle().clone();
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: false,
connection: None,
};
let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F2),
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
assert!(
canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.is_none()
);
let _ = canvas.update(
&mut state,
&CanvasEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(iced::Point::new(150., 120.)),
);
let action = canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.unwrap();
let message = action.into_inner().0.unwrap();
let _ = app.update(message);
assert!(app.architecture.as_ref().unwrap().inline());
assert!(matches!(&app.architecture.as_ref().unwrap().saved.intent,
Intent::Rename { element: id, name } if *id == element && name == "Original team"));
send(&mut app, Event::Name(" Renamed team 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
assert_eq!(app.project().elements[&element].name, " Renamed team 雨 ");
assert_eq!(app.project().diagrams, diagrams);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_blank_names_and_escape_never_accept_placeholder_or_rename() {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Actor, "Dispatcher");
let before = app.document.bundle().clone();
let context = app.route_context().unwrap();
for event in [
Event::Start(Kind::Activity, Some(Position { x: 220., y: 330. })),
Event::Rename(context, element, Position { x: 150., y: 200. }),
] {
// Every cancel changes the gesture context; use the current context for F2.
let event = match event {
Event::Rename(_, element, point) => {
Event::Rename(app.route_context().unwrap(), element, point)
}
event => event,
};
send(&mut app, event);
send(&mut app, Event::Name(" \t ".into()));
send(&mut app, Event::Submit);
assert!(app.architecture.as_ref().unwrap().inline());
assert!(app.architecture.as_ref().unwrap().error.is_some());
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn architecture_invalid_click_cannot_poison_inline_or_checkpoint_geometry() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
let before = app.document.bundle().clone();
let context = app.route_context().unwrap();
for point in [
Position { x: f32::NAN, y: 0. },
Position {
x: 0.,
y: f32::INFINITY,
},
] {
send(&mut app, Event::Click(context, point));
let draft = app.architecture.as_ref().unwrap();
assert!(draft.unnamed_tool());
assert!(validate_saved(app.project(), &draft.saved).is_ok());
assert_eq!(app.document.bundle(), &before);
}
send(&mut app, Event::Click(context, Position { x: 25., y: 30. }));
assert!(app.architecture.as_ref().unwrap().inline());
}
#[test]
fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
for rename in [false, true] {
for stale_document in [false, true] {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Entity, "Accepted team");
let point = Position {
x: 151.5,
y: 227.25,
};
let event = if rename {
Event::Rename(app.route_context().unwrap(), element, point)
} else {
Event::Start(Kind::Activity, Some(point))
};
send(&mut app, event);
send(
&mut app,
Event::Name(" Retained engineering name 雨 ".into()),
);
// Change only the binding. The accepted model/source must not be
// rewritten or a rejected draft silently rebound to the new context.
if stale_document {
app.architecture.as_mut().unwrap().document = Id::new_v4();
} else {
app.architecture.as_mut().unwrap().revision = app.document.revision() + 1;
}
let draft = app.architecture.as_ref().unwrap();
let saved = serde_json::to_value(&draft.saved).unwrap();
let binding = (draft.document, draft.revision);
let model = app.project().clone();
let source = app.document.bundle().clone();
let revision = app.document.revision();
let camera = (app.zoom, app.pan);
for _ in 0..2 {
send(&mut app, Event::Submit);
let draft = app.architecture.as_ref().unwrap();
assert!(
draft.inline(),
"rename={rename}, stale_document={stale_document}"
);
assert!(inline_editor(&app).is_some());
assert_eq!(draft.error.as_deref(), Some(app.status.as_str()));
assert!(app.status.contains("cancel and restart"));
assert_eq!(serde_json::to_value(&draft.saved).unwrap(), saved);
assert_eq!((draft.document, draft.revision), binding);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.zoom, app.pan), camera);
}
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
let fresh = create(&mut app, Kind::Actor, "Fresh context");
assert_eq!(app.project().elements[&fresh].name, "Fresh context");
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
}
}
}
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard;
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn focused_text_input_escape(name: &str) -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
.id("architecture-name")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(field.as_widget());
let size = iced::Size::new(300., 40.);
let node =
field
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let escape = key_event(iced::keyboard::key::Named::Escape);
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
field.as_widget_mut().update(
&mut tree,
&escape,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(
shell.is_event_captured(),
"Iced consumes focused-input Escape"
);
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
captured_escape(
escape,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap()
}
#[test]
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
for rename in [false, true] {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Actor, "Accepted dispatcher");
let model = app.project().clone();
let source = app.document.bundle().clone();
let revision = app.document.revision();
let camera = (app.zoom, app.pan);
let point = Position { x: 439., y: 328.2 };
let event = if rename {
Event::Rename(app.route_context().unwrap(), element, point)
} else {
Event::Start(Kind::Actor, Some(point))
};
send(&mut app, event);
let name = " Unaccepted long engineering name 雨 ";
send(&mut app, Event::Name(name.into()));
assert!(app.architecture.as_ref().unwrap().inline());
let message = focused_text_input_escape(name);
let _ = app.update(message);
assert!(app.architecture.is_none());
assert!(inline_editor(&app).is_none());
assert_eq!(app.status, "Cancelled · Select / move");
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.zoom, app.pan), camera);
}
}
#[test]
fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
let mut app = setup(Layer::Oa);
for event in [
None,
Some(Event::Start(Kind::Entity, None)),
Some(Event::Existing(None)),
] {
send(&mut app, Event::Cancel);
if let Some(event) = event {
send(&mut app, event);
}
let saved = app
.architecture
.as_ref()
.map(|s| serde_json::to_value(&s.saved).unwrap());
let before = app.document.bundle().clone();
let status = app.status.clone();
let epoch = app.canvas_epoch;
let message = focused_text_input_escape("Other field or existing-element search");
let _ = app.update(message);
assert_eq!(
app.architecture
.as_ref()
.map(|s| serde_json::to_value(&s.saved).unwrap()),
saved
);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.status, status);
assert_eq!(app.canvas_epoch, epoch);
}
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Escape),
iced::event::Status::Ignored,
iced::window::Id::unique(),
)
.is_none()
);
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Enter),
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.is_none()
);
}
@@ -0,0 +1,488 @@
use super::*;
use crate::app::architecture_authoring::{Event, kinds};
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
app.layer = layer;
app.active_diagram = None;
let _ = app.update(Message::AddDiagram);
app.zoom = 0.75;
app.pan = iced::Vector::new(37., -19.);
app
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Architecture(event));
}
#[test]
fn architecture_creation_names_before_commit_preserves_camera_and_undoes_once() {
let mut app = setup(Layer::Oa);
let before = app.document.bundle().clone();
let model = app.project().clone();
let camera = (app.zoom, app.pan);
send(&mut app, Event::Start(Kind::Entity, None));
assert_eq!(app.project(), &model);
let context = app.route_context().unwrap();
send(
&mut app,
Event::Click(context, Position { x: 219., y: 117. }),
);
assert_eq!(app.project(), &model);
assert!(app.architecture.as_ref().unwrap().inline());
send(&mut app, Event::Name(" Response team 雨 ".into()));
send(&mut app, Event::Submit);
assert!(app.architecture.is_none());
assert_eq!((app.zoom, app.pan), camera);
let element = &app.project().elements[&app.selected];
assert_eq!(element.name, " Response team 雨 ");
assert_eq!(element.parent, None);
let node =
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
assert_eq!(node.position, Position { x: 219., y: 117. });
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_context_choices_are_small_and_system_is_singleton() {
let mut app = setup(Layer::Oa);
assert_eq!(kinds(&app), vec![Kind::Entity, Kind::Actor, Kind::Activity]);
app = setup(Layer::Sa);
assert_eq!(
kinds(&app),
vec![Kind::System, Kind::SystemActor, Kind::Function]
);
send(
&mut app,
Event::Start(Kind::System, Some(Position { x: 60., y: 60. })),
);
send(&mut app, Event::Name("Pump controller".into()));
send(&mut app, Event::Submit);
assert_eq!(kinds(&app), vec![Kind::SystemActor, Kind::Function]);
}
#[test]
fn architecture_name_survives_rejected_click_resize_navigation_and_escape_is_explicit() {
let mut app = setup(Layer::Oa);
let before = app.document.bundle().clone();
send(&mut app, Event::Start(Kind::Activity, None));
send(&mut app, Event::Name("Diagnose pressure".into()));
send(&mut app, Event::Start(Kind::Entity, None));
assert!(
matches!(&app.architecture.as_ref().unwrap().saved.intent, architecture_authoring::Intent::Create { name, kind: Kind::Activity, .. } if name == "Diagnose pressure")
);
send(&mut app, Event::Submit);
let mut stale = app.route_context().unwrap();
stale.revision += 1;
send(&mut app, Event::Click(stale, Position { x: 1., y: 2. }));
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::WindowResized(iced::Size::new(1100., 700.)));
assert_eq!(app.page, Page::Diagram);
assert!(app.architecture.is_some());
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn architecture_existing_inventory_is_contextual_and_placement_never_implies_allocation() {
let mut app = setup(Layer::Oa);
let entity = app
.project()
.make_element(Kind::Entity, Layer::Oa, "Response team", None)
.unwrap();
let entity_id = entity.id;
let work = app
.project()
.make_element(Kind::Activity, Layer::Oa, "Report status", None)
.unwrap();
let work_id = work.id;
let data = app
.project()
.make_element(Kind::DataType, Layer::Oa, "Pressure", None)
.unwrap();
let data_id = data.id;
assert!(app.execute(Command::Batch(vec![
Command::CreateElement(entity),
Command::CreateElement(work),
Command::CreateElement(data)
])));
send(&mut app, Event::Existing(Some(Position { x: 80., y: 80. })));
let ids: Vec<_> = architecture_authoring::candidates(&app)
.iter()
.map(|row| row.0)
.collect();
assert!(ids.contains(&entity_id) && ids.contains(&work_id));
assert!(!ids.contains(&data_id));
send(&mut app, Event::Pick(entity_id));
let semantic = app.project().elements.clone();
let before = app.document.bundle().clone();
let camera = (app.zoom, app.pan);
send(
&mut app,
Event::Existing(Some(Position { x: 90., y: 100. })),
);
assert!(
!architecture_authoring::candidates(&app)
.iter()
.any(|row| row.0 == entity_id)
);
send(&mut app, Event::ShowPlaced(true));
assert!(
architecture_authoring::candidates(&app)
.iter()
.any(|row| row.0 == entity_id && row.2)
);
send(&mut app, Event::Pick(work_id));
assert_eq!(app.project().elements, semantic);
assert!(app.project().relations.is_empty());
assert_eq!((app.zoom, app.pan), camera);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn architecture_checkpoint_retains_exact_name_point_and_fresh_binding_without_commands() {
for (name, placing) in [(" Team 雨 ", true), (" ", false)] {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
send(&mut app, Event::Name(name.into()));
if placing {
send(&mut app, Event::Submit);
}
app.architecture.as_mut().unwrap().saved.point = Some(Position { x: 127.5, y: 215. });
app.architecture.as_mut().unwrap().saved.placing = placing;
let snapshot = crate::recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
None,
app.selected,
)
.unwrap()
.with_architecture(app.architecture.as_ref());
let bytes = serde_json::to_vec(&snapshot).unwrap();
let decoded: crate::recovery::Snapshot = serde_json::from_slice(&bytes).unwrap();
let accepted = app.document.bundle().clone();
let mut restored = setup(Layer::Sa);
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(decoded))));
assert_eq!(restored.document.bundle(), &accepted);
let draft = &restored.architecture.as_ref().unwrap().saved;
assert!(
matches!(&draft.intent, architecture_authoring::Intent::Create { name: actual, .. } if actual == name)
);
assert_eq!(draft.placing, placing);
assert_eq!(draft.point, Some(Position { x: 127.5, y: 215. }));
if placing {
let context = restored.route_context().unwrap();
send(
&mut restored,
Event::Click(context, Position { x: 20., y: 30. }),
);
assert!(restored.architecture.as_ref().unwrap().inline());
send(&mut restored, Event::Submit);
assert!(restored.architecture.is_none());
assert_eq!(restored.project().elements[&restored.selected].name, name);
} else {
send(&mut restored, Event::Submit);
assert!(restored.architecture.is_some());
assert_eq!(restored.document.bundle(), &accepted);
}
let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
value["architecture"]["base"] = json!("stale");
assert!(
serde_json::from_value::<crate::recovery::Snapshot>(value)
.unwrap()
.restore()
.is_err()
);
}
}
#[test]
fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
use iced::widget::canvas::Program;
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Actor, None));
send(&mut app, Event::Name("Control centre".into()));
send(&mut app, Event::Submit);
let bounds = iced::Rectangle {
x: 260.,
y: 180.,
width: 600.,
height: 380.,
};
let local = iced::Point::new(190., 100.);
let expected = Position {
x: (local.x - app.pan.x) / app.zoom,
y: (local.y - app.pan.y) / app.zoom,
};
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: true,
connection: None,
};
let mut state = crate::diagram::State::default();
let action = canvas
.update(
&mut state,
&iced::widget::canvas::Event::Mouse(iced::mouse::Event::ButtonPressed(
iced::mouse::Button::Left,
)),
bounds,
iced::mouse::Cursor::Available(iced::Point::new(
bounds.x + local.x,
bounds.y + local.y,
)),
)
.unwrap();
let message = action
.into_inner()
.0
.expect("named placement emits a command");
let _ = app.update(message);
assert_eq!(
app.architecture.as_ref().unwrap().saved.point,
Some(expected)
);
send(&mut app, Event::Submit);
let node =
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
assert_eq!(node.position, expected);
}
#[test]
fn architecture_cancelled_gesture_cannot_commit_a_later_named_draft() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
send(&mut app, Event::Name("First".into()));
send(&mut app, Event::Submit);
let stale = app.route_context().unwrap();
send(&mut app, Event::Cancel);
send(&mut app, Event::Start(Kind::Actor, None));
send(&mut app, Event::Name("Second".into()));
send(&mut app, Event::Submit);
let before = app.document.bundle().clone();
send(&mut app, Event::Click(stale, Position { x: 1., y: 1. }));
assert_eq!(app.document.bundle(), &before);
assert!(app.architecture.is_some());
}
#[test]
fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
use iced::{keyboard, mouse, widget::canvas::Program};
let mut app = setup(Layer::Oa);
send(
&mut app,
Event::Start(Kind::Entity, Some(Position { x: 200., y: 120. })),
);
send(&mut app, Event::Name("Response team".into()));
send(&mut app, Event::Submit);
let before = app.document.bundle().clone();
let element = app.selected;
let node = app.selected_node;
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: false,
connection: None,
};
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
let mut state = crate::diagram::State::default();
let key = iced::widget::canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F2),
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
assert!(canvas.update(&mut state, &key, bounds, cursor).is_none());
let _ = canvas.update(
&mut state,
&iced::widget::canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
cursor,
);
let message = canvas
.update(&mut state, &key, bounds, cursor)
.expect("focused F2 opens the selected semantic name")
.into_inner()
.0
.unwrap();
let _ = app.update(message);
assert!(app.architecture.as_ref().unwrap().inline());
send(&mut app, Event::Name(" Emergency team 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none());
assert_eq!((app.selected, app.selected_node), (element, node));
assert_eq!(
app.project().elements[&element].name,
" Emergency team 雨 "
);
assert_eq!(app.project().diagrams, diagrams);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
let context = app.route_context().unwrap();
send(
&mut app,
Event::Rename(context, element, Position { x: 200., y: 120. }),
);
send(&mut app, Event::Name("Cancelled name".into()));
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_contextual_allocation_creates_work_relation_and_node_in_one_undo() {
for (layer, performer_kind, work_kind) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
let mut app = setup(layer);
send(
&mut app,
Event::Start(performer_kind, Some(Position { x: 20., y: 20. })),
);
send(&mut app, Event::Name("Control team".into()));
send(&mut app, Event::Submit);
let performer = app.selected;
let before = app.document.bundle().clone();
send(
&mut app,
Event::Allocate(work_kind, performer, Some(Position { x: 55., y: 90. })),
);
send(&mut app, Event::Name("Assess pressure".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
let work = app.selected;
assert_eq!(app.project().elements[&work].parent, None);
assert!(
app.project()
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation
&& r.source() == performer
&& r.target() == work)
);
let node = &app.project().diagrams[&app.active_diagram.unwrap()].nodes
[&app.selected_node.unwrap()];
assert_eq!(node.position, Position { x: 55., y: 90. });
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
}
#[test]
fn architecture_rejected_submission_keeps_inline_name_visible_and_invalid_click_is_ignored() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
let context = app.route_context().unwrap();
send(
&mut app,
Event::Click(
context,
Position {
x: f32::NAN,
y: 30.,
},
),
);
assert!(app.architecture.as_ref().unwrap().unnamed_tool());
send(&mut app, Event::Click(context, Position { x: 40., y: 30. }));
send(&mut app, Event::Name("Retained team".into()));
// A replaced document must not accept a queued draft submission.
app.canvas_document = Id::new_v4();
let before = app.document.bundle().clone();
send(&mut app, Event::Submit);
let draft = app.architecture.as_ref().unwrap();
assert!(draft.inline());
assert!(draft.error.is_some());
assert!(
matches!(&draft.saved.intent, architecture_authoring::Intent::Create { name, .. } if name == "Retained team")
);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture() {
use crate::workflow::{Event as W, Workflow};
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Report status", None)
.unwrap();
let architecture = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
let mut first = Workflow::transition(&p, work).unwrap();
let Workflow::Transition(t) = &first else {
unreachable!()
};
let target = t
.plan
.changes
.iter()
.find(|c| c.source == work)
.unwrap()
.target;
let mut p = first.update(&p, W::Apply).unwrap().unwrap();
let mut overview = p.diagrams[&architecture].clone();
overview.id = Id::new_v4();
overview.alias = "chosen_overview".into();
overview.name = "Chosen system overview".into();
overview.projection = ProjectionKind::Overview;
overview.nodes.clear();
let chosen = overview.id;
p.diagrams.insert(chosen, overview);
let placement = p.place(chosen, target, Position { x: 823., y: 417. });
let mut app = App::with_project(p);
app.selected = work;
app.active_diagram = Some(architecture);
app.refresh_draft();
let _ = app.update(Message::StartTransition);
let _ = app.update(Message::Workflow(W::Destination(Choice {
id: chosen,
label: "Chosen system overview".into(),
})));
let before = app.project().diagrams.clone();
let _ = app.update(Message::Workflow(W::Apply));
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(app.active_diagram, Some(chosen));
assert_eq!(app.selected, target);
assert_eq!(app.selected_node, Some(placement));
assert_eq!(app.project().diagrams, before);
}
@@ -322,14 +322,18 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
Message::Field("/name".into(), "Unapplied state name".into()),
);
assert!(app.draft_dirty());
let context = behavior::topology::Context {
document: app.canvas_document,
owner: fixture.machine,
revision: app.document.revision(),
epoch: app.canvas_epoch,
};
for event in [
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
900., 600.,
))),
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
behavior::Event::Topology(behavior::topology::Event::Fit),
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
behavior::topology::CanvasEvent::Zoom(context, None),
] {
send(app, Message::Behavior(event));
send(app, Message::TopologyCanvas(event));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.draft_dirty());
assert!(!app.behavior.has_pending());
+713
View File
@@ -0,0 +1,713 @@
//! Home navigation and asynchronous private checkpoints share the normal guards.
use super::*;
use crate::{home, recovery};
impl App {
pub(super) fn remember_local(&mut self) {
if let Some(path) = self.path.clone() {
let name = self.project().name.clone();
if let Err(error) = self.home.remember(&path, &name) {
self.home.notice = Some(error);
}
}
}
pub(super) fn home_event(&mut self, event: home::Event) -> Task<Message> {
match event {
home::Event::New => self.handle(Message::Request(Pending::New)),
home::Event::Open => self.handle(Message::Request(Pending::Open)),
home::Event::Example => self.handle(Message::Request(Pending::Demo)),
home::Event::Walkthrough => self.handle(Message::Request(Pending::Walkthrough)),
home::Event::Guide => self.update(Message::Page(Page::Guide)),
home::Event::Resume => self.update(Message::Page(self.last_workspace_page)),
home::Event::OpenRecent(path) => {
self.recent_target = Some(path);
self.handle(Message::Request(Pending::Recent))
}
home::Event::Recover(id) => self.handle(Message::Request(Pending::Recover(id))),
home::Event::Forget(path) => {
if let Err(error) = self.home.forget(&path) {
self.home.notice = Some(error);
}
Task::none()
}
home::Event::Refresh => {
self.home.refresh();
self.recovery.refresh();
self.recovery.entries.retain(|entry| {
entry.id != self.canvas_document && Some(entry.id) != self.recovery.restored
});
Task::none()
}
}
}
pub(super) fn checkpoint_tick(&mut self) -> Task<Message> {
if self.recovery.writing || self.busy {
return Task::none();
}
if !self.dirty() {
self.retire_checkpoint();
return Task::none();
}
let Some(directory) = self.recovery.directory.clone() else {
return Task::none();
};
let id = self.canvas_document;
let snapshot = match recovery::Snapshot::capture(
id,
&self.document,
&self.source_workspace,
self.draft.as_ref(),
&self.behavior,
self.path.clone(),
self.selected,
)
.and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref()))
.map(|snapshot| snapshot.with_architecture(self.architecture.as_ref()))
{
Ok(snapshot) => snapshot,
Err(error) => {
self.recovery.warning = Some(error);
return Task::none();
}
};
self.recovery.writing = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || recovery::write(&directory, &snapshot))
.await
.map_err(|e| e.to_string())?
},
move |result| Message::CheckpointWritten(id, result),
)
}
pub(super) fn checkpoint_written(&mut self, id: Id, result: Result<(), String>) {
self.recovery.writing = false;
match result {
Ok(()) if id == self.canvas_document && self.dirty() => {
self.recovery.active = Some(id);
}
Ok(()) => {
if let Some(directory) = &self.recovery.directory
&& let Err(error) = recovery::remove(directory, id)
{
self.recovery.warning = Some(error);
}
}
Err(error) => {
self.recovery.warning = Some(format!(
"Recovery checkpoint failed: {error}. Save your work explicitly."
))
}
}
}
pub(super) fn retire_checkpoint(&mut self) {
let Some(directory) = &self.recovery.directory else {
return;
};
for id in [self.recovery.active.take(), self.recovery.restored.take()]
.into_iter()
.flatten()
{
if let Err(error) = recovery::remove(directory, id) {
self.recovery.warning = Some(error);
} else {
self.recovery.entries.retain(|e| e.id != id);
}
}
}
pub(super) fn load_checkpoint(&mut self, id: Id) -> Task<Message> {
let Some(directory) = self.recovery.directory.clone() else {
return Task::none();
};
self.busy = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || recovery::read(&directory, id).map(Arc::new))
.await
.map_err(|e| e.to_string())?
},
Message::CheckpointLoaded,
)
}
pub(super) fn checkpoint_loaded(
&mut self,
result: Result<Arc<recovery::Snapshot>, String>,
) -> Task<Message> {
self.busy = false;
let snapshot = match result {
Ok(s) => s,
Err(error) => {
self.status = error;
return Task::none();
}
};
let id = snapshot.id;
let restored = match (*snapshot).clone().restore() {
Ok(r) => r,
Err(error) => {
self.status = error;
return Task::none();
}
};
// Fully prepare recovery before touching the currently accepted document.
self.reset(restored.document.project().clone(), None);
self.hosted.leave_document();
self.document = restored.document;
self.migration = self.document.compiled().decoded.migration.clone();
self.selected = if self.project().layer_of(restored.selected).is_some()
|| restored.selected == self.project().id
{
restored.selected
} else {
self.project().id
};
// Installing the raw workspace must follow all accepted-source synchronization.
self.source_workspace = restored.workspace;
self.show_source_file(false);
self.refresh_draft();
if let Some(draft) = restored.property {
self.selected = draft.original;
self.exact_strings = draft.exact_strings;
self.draft = Some(draft);
}
self.behavior = restored.behavior;
self.workflow = restored.workflow;
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
if let Some(layer) = self
.behavior
.creation
.pending_layer()
.or_else(|| self.project().layer_of(focus))
{
self.layer = layer;
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
self.active_diagram = self.diagram_for(layer, focus);
}
self.recovery.restored = Some(id);
self.recovery.entries.retain(|entry| entry.id != id);
self.page = if self.behavior.has_pending() {
Page::Behavior
} else if self.source_dirty() {
Page::Text
} else {
Page::Diagram
};
if let Some(saved) = restored.architecture {
self.active_diagram = Some(saved.diagram);
self.layer = self.project().diagrams[&saved.diagram].layer;
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.page = Page::Diagram;
self.architecture = architecture_authoring::State::restore(self, saved).ok();
}
self.status = if let Some(report) = &self.migration {
format!(
"Recovered source {} as source {} in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it.",
report.from_schema, report.to_schema
)
} else {
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
};
let lint = self.request_lint();
if self
.architecture
.as_ref()
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.source_open() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
])
} else {
lint
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_resume_returns_to_each_actual_workspace_without_model_edits() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
for page in [
Page::Diagram,
Page::Catalog,
Page::Behavior,
Page::Data,
Page::Traces,
Page::Text,
Page::Validation,
Page::Guide,
] {
let _ = app.update(Message::Page(page));
assert_eq!(app.page, page);
let selected = app.selected;
let _ = app.update(Message::Page(Page::Home));
assert_eq!(app.last_workspace_page, page);
// Repeated Home events must not replace the remembered workspace.
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, page);
assert_eq!(app.selected, selected);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn home_resume_keeps_state_view_while_explicit_architecture_remains_reachable() {
let mut project = Project::empty();
let owner = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = project
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
.unwrap();
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
project = prepare_command(&project, &plan.command).unwrap().project;
let mut app = App::with_project(project);
assert_eq!(app.page, Page::Behavior);
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, plan.diagram);
assert_eq!(app.behavior.active_owner, Some(machine));
let _ = app.update(Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert!(app.active_diagram.is_none());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Diagram);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn home_resume_retains_invalid_source_and_property_drafts() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Text));
let raw = format!("{}\nunfinished draft", app.source_workspace.text());
app.source_workspace
.edit(raw.clone(), app.source_editor.cursor());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Text);
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(Message::Field("/id".into(), "-".into()));
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(app.draft_dirty());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Data);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn walkthrough_request_keeps_unfinished_source_behind_the_normal_pending_guard() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let raw = format!(
"{}\nunfinished walkthrough draft",
app.source_workspace.text()
);
app.source_workspace
.edit(raw.clone(), app.source_editor.cursor());
let task = app.update(Message::Home(home::Event::Walkthrough));
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
assert!(!app.busy);
assert!(iced_runtime::task::into_stream(task).is_none());
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(app.pending.is_none());
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
fn retained_snapshot(app: &App) -> recovery::Snapshot {
recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
app.path.clone(),
app.selected,
)
.unwrap()
}
async fn run_recovery_tasks(app: &mut App, task: Task<Message>) -> (usize, bool, bool) {
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
// Mount real inputs, with focus initially in another control. This
// verifies the returned operation targets the naming field by its ID.
let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![
iced::widget::text_input("Engineering name", "Retained name")
.id("architecture-name")
.on_input(|_| ()),
iced::widget::text_input("Other control", "Other value")
.id("unrelated-field")
.on_input(|_| ())
]
.into();
let mut tree = widget::Tree::new(fields.as_widget());
let node = fields.as_widget_mut().layout(
&mut tree,
&(),
&layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)),
);
tree.children[1]
.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let mut operations = 0;
let mut lint_completions = 0;
let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint");
while let Some(action) = stream.next().await {
match action {
iced_runtime::Action::Widget(mut operation) => {
operations += 1;
fields.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&(),
operation.as_mut(),
);
}
iced_runtime::Action::Output(message @ Message::Linted(_, _)) => {
lint_completions += 1;
let _ = app.update(message);
}
_ => panic!("Unexpected recovery task action"),
}
}
assert_eq!(
lint_completions, 1,
"Autofocus must not replace request_lint"
);
assert!(app.lint_running.is_none());
let focused = |index: usize| {
tree.children[index]
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
};
(operations, focused(0), focused(1))
}
fn architecture_recovery_app() -> App {
let mut app = App::with_project(Project::empty());
let _ = app.update(Message::AddDiagram);
let _ = app.update(Message::Architecture(architecture_authoring::Event::Start(
Kind::Entity,
Some(Position { x: 80., y: 90. }),
)));
let _ = app.update(Message::Architecture(architecture_authoring::Event::Name(
"Accepted team".into(),
)));
let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit));
assert!(app.architecture.is_none());
app
}
#[tokio::test]
async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history()
{
use architecture_authoring::Event;
for rename in [false, true] {
let mut app = architecture_recovery_app();
let point = Position { x: 439., y: 328.2 };
let event = if rename {
Event::Rename(app.route_context().unwrap(), app.selected, point)
} else {
Event::Start(Kind::Actor, Some(point))
};
let _ = app.update(Message::Architecture(event));
let _ = app.update(Message::Architecture(Event::Name(
" Recovered engineering name 雨 ".into(),
)));
let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap();
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
let expected = snapshot.clone().restore().unwrap();
let checkpoint = snapshot.id;
let mut recovered = App::with_project(Project::empty());
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert_eq!(
run_recovery_tasks(&mut recovered, task).await,
(1, true, false)
);
assert_eq!(recovered.page, Page::Diagram);
assert!(recovered.architecture.as_ref().unwrap().inline());
assert_eq!(
serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(),
saved
);
assert_eq!(recovered.project(), expected.document.project());
assert_eq!(recovered.document.bundle(), expected.document.bundle());
assert_eq!(recovered.document.revision(), expected.document.revision());
assert_eq!(recovered.document.can_undo(), expected.document.can_undo());
assert_eq!(recovered.document.can_redo(), expected.document.can_redo());
assert_eq!(
recovered.source_workspace.bundle(),
expected.workspace.bundle()
);
assert_eq!(recovered.recovery.restored, Some(checkpoint));
assert!(recovered.path.is_none());
}
}
#[tokio::test]
async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() {
use architecture_authoring::Event;
for context in ["accepted", "armed", "picker", "property", "source"] {
let mut app = architecture_recovery_app();
match context {
"armed" => {
let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None)));
}
"picker" => {
let _ = app.update(Message::Architecture(Event::Existing(None)));
}
"property" => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied property".into());
assert!(app.draft_dirty());
}
"source" => {
app.source_workspace
.edit("incomplete source 雨".into(), app.source_editor.cursor());
assert!(app.source_dirty());
}
_ => {}
}
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
let expected = snapshot.clone().restore().unwrap();
let mut recovered = App::with_project(Project::empty());
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert_eq!(
run_recovery_tasks(&mut recovered, task).await,
(0, false, true),
"{context}"
);
assert!(
!recovered
.architecture
.as_ref()
.is_some_and(architecture_authoring::State::inline)
);
assert_eq!(recovered.project(), expected.document.project());
assert_eq!(recovered.document.bundle(), expected.document.bundle());
assert_eq!(recovered.document.revision(), expected.document.revision());
assert_eq!(
recovered.source_workspace.bundle(),
expected.workspace.bundle()
);
}
}
#[test]
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().join("recovery"));
let id = app.project().id;
app.execute(Command::Rename(id, "Saved milestone".into()));
let path = directory.path().join(&app.document.bundle().entry);
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path,
None,
)
.unwrap();
app.execute(Command::Rename(id, "Later unsaved engineering".into()));
let snapshot = retained_snapshot(&app);
let recovery_directory = app.recovery.directory.clone().unwrap();
recovery::write(&recovery_directory, &snapshot).unwrap();
app.recovery.active = Some(app.canvas_document);
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(app.dirty());
assert_eq!(app.project().name, "Later unsaved engineering");
assert_eq!(app.recovery.active, Some(app.canvas_document));
let restored = recovery::read(&recovery_directory, app.canvas_document)
.unwrap()
.restore()
.unwrap();
assert_eq!(
restored.document.project().name,
"Later unsaved engineering"
);
}
#[test]
fn checkpoint_completion_releases_the_writer_while_a_context_menu_is_open() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
let id = app.project().id;
app.execute(Command::Rename(id, "Unsaved engineering".into()));
let snapshot = retained_snapshot(&app);
recovery::write(directory.path(), &snapshot).unwrap();
app.recovery.writing = true;
let _ = app.update(Message::Menu(crate::model_menu::Event::Open(
crate::model_menu::Request::object(id, iced::Point::ORIGIN),
)));
assert!(app.model_menu.is_some());
let _ = app.update(Message::CheckpointWritten(app.canvas_document, Ok(())));
assert!(
!app.recovery.writing,
"A passive completion cannot be swallowed by an open menu"
);
assert_eq!(app.recovery.active, Some(app.canvas_document));
assert!(
app.model_menu.is_some(),
"Saving a checkpoint does not dismiss the menu"
);
assert_eq!(app.project().name, "Unsaved engineering");
}
#[test]
fn discard_and_close_waits_for_the_late_checkpoint_then_retires_it() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
app.execute(Command::Rename(
app.project().id,
"Discarded on close".into(),
));
let snapshot = retained_snapshot(&app);
let document = app.canvas_document;
app.recovery.writing = true;
let window = window::Id::unique();
let _ = app.update(Message::Request(Pending::Close(window)));
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
let closing = app.update(Message::Discard);
assert!(
iced_runtime::task::into_stream(closing).is_none(),
"The window must remain until its outstanding writer finishes"
);
assert_eq!(app.checkpoint_close, Some(window));
assert!(app.busy);
recovery::write(directory.path(), &snapshot).unwrap();
assert!(recovery::read(directory.path(), document).is_ok());
let close = app.update(Message::CheckpointWritten(document, Ok(())));
assert!(
iced_runtime::task::into_stream(close).is_some(),
"Close is emitted after checkpoint cleanup"
);
assert!(app.checkpoint_close.is_none());
assert!(recovery::read(directory.path(), document).is_err());
assert!(app.recovery.active.is_none());
}
#[test]
fn recent_and_recovery_navigation_respect_unsaved_decision() {
let mut app = App::with_project(Project::demo());
let id = app.project().id;
app.execute(Command::Rename(id, "Retained work".into()));
let _ = app.update(Message::Home(home::Event::OpenRecent(PathBuf::from(
"missing.arcadia",
))));
assert!(matches!(app.pending, Some(Pending::Recent)));
assert!(!app.busy);
assert_eq!(app.project().name, "Retained work");
let _ = app.update(Message::Cancel);
let recovery_id = Id::new_v4();
let _ = app.update(Message::Home(home::Event::Recover(recovery_id)));
assert!(matches!(app.pending,Some(Pending::Recover(id)) if id==recovery_id));
assert!(!app.busy);
}
#[test]
fn restored_copy_keeps_raw_property_and_requires_save_without_local_or_hosted_binding() {
let mut app = App::with_project(Project::demo());
let selected = app.project().id;
app.draft
.as_mut()
.unwrap()
.input("/name", "Not yet applied".into());
let snapshot = recovery::Snapshot::capture(
Id::new_v4(),
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
Some(PathBuf::from("original.arcadia")),
selected,
)
.unwrap();
let checkpoint_id = snapshot.id;
app.recovery.entries.push(recovery::Entry {
id: checkpoint_id,
name: "Retained work".into(),
origin: None,
modified: snapshot.modified,
});
let _ = app.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert!(app.path.is_none());
assert!(app.disk_project.is_none());
assert!(app.hosted.session.is_none());
assert!(app.document.dirty());
assert!(app.draft_dirty());
assert_eq!(app.recovery.restored, Some(checkpoint_id));
assert!(
app.recovery.entries.is_empty(),
"Home must not offer the copy currently being recovered again"
);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Not yet applied"
);
assert_ne!(app.project().name, "Not yet applied");
}
#[test]
fn stale_write_completion_cannot_recover_a_discarded_document() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
let old = app.canvas_document;
let s = recovery::Snapshot::capture(
old,
&app.document,
&app.source_workspace,
None,
&app.behavior,
None,
app.selected,
)
.unwrap();
app.reset(Project::empty(), None);
recovery::write(directory.path(), &s).unwrap();
app.checkpoint_written(old, Ok(()));
assert!(recovery::read(directory.path(), old).is_err());
assert!(app.recovery.active.is_none());
}
#[test]
fn malformed_checkpoint_does_not_replace_the_current_model() {
let mut app = App::with_project(Project::demo());
let original = app.document.bundle().clone();
let _ = app.checkpoint_loaded(Err("Damaged checkpoint".into()));
assert_eq!(app.document.bundle(), &original);
assert!(app.status.contains("Damaged"));
}
}

Some files were not shown because too many files have changed in this diff Show More