From a09790754909bf1fb71299f6e0fb3929d96c6048 Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 3 Nov 2026 11:16:22 +0530 Subject: [PATCH 01/13] docs: plan requirements diagrams and parallel implementation --- README.md | 2 +- docs/README.md | 2 + docs/architecture/requirements-views.md | 109 ++++++++++++++++++++++++ docs/next-steps.md | 14 +-- 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/requirements-views.md diff --git a/README.md b/README.md index afc4191..08d256e 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Start with the [documentation index](docs/README.md) for current capabilities, a This implementation supports a bounded source/structure editing workflow. It does not claim complete SysML v2 language, notation, execution or interchange conformance. Owned-port boundary notation, effective conjugated directions, behavior renderers and broader editing commands remain explicit gaps. -Flexo MMS checkout and publishing are planned; the current workbench uses local project files. Read the [integration research](docs/research/openmbee-flexo-mms.md) and [roadmap](docs/next-steps.md#3-flexo-checkout-then-reviewed-publication) for the proposed direction. +Flexo MMS checkout and publishing are planned; the current workbench uses local project files. Read the [integration research](docs/research/openmbee-flexo-mms.md) and [roadmap](docs/next-steps.md#4-flexo-checkout-then-reviewed-publication) for the proposed direction. ## Contributing diff --git a/docs/README.md b/docs/README.md index 5df8c1b..ef6d355 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,8 @@ OpenMBEE View Editor is a sandbox browser workspace for SysML v2 source and name ## Planned work and research +- [Requirements and satisfaction diagrams](architecture/requirements-views.md): active feature-session plan, engine gates, renderer scope and preservation acceptance; not implemented. + - [Model exploration](architecture/model-exploration.md): agreed behavior for browsing models without authored views; not implemented. - [Exploration engine investigation](research/automatic-exploration-contract.md): pinned renderer/WASM evidence and the next contract boundary. - [Flexo MMS integration](research/openmbee-flexo-mms.md): dated source research and unresolved interchange/concurrency gates; not implemented. diff --git a/docs/architecture/requirements-views.md b/docs/architecture/requirements-views.md new file mode 100644 index 0000000..9204d81 --- /dev/null +++ b/docs/architecture/requirements-views.md @@ -0,0 +1,109 @@ +# Requirements and satisfaction diagrams + +Planned session, 2026-11-03, on `feature/requirements-traceability`. This is an implementation plan, not a capability claim. Agreed scope: requirements, their hierarchy and declared satisfaction relationships. Start read-only for model semantics; presentation movement and source navigation are permitted. + +## User outcome + +Open the requirements example or the compressor project, choose an appropriate authored view, and understand: + +- Which requirement definitions and usages are shown, including available declared identifiers, names and documentation. +- Which requirements contain other requirements, independently of definition/usage typing. +- Which exact model elements have declared, resolved satisfaction relationships to those requirements. +- Where each element and relationship is declared in source, including repeated usages with distinct context. + +A satisfaction declaration is not proof that a requirement is met. Do not display pass/fail, verified status or global coverage percentages. An absent visible link means no satisfaction link is shown in this scope; it does not establish that the whole model has no satisfier. + +## Starting evidence and gates + +`Diagram.tsx` currently accepts only tree, interconnection and definition artifacts. The capacity requirements fixture is catalogued as a native `requirement` artifact with `MinimumCapacity`, `usableCapacity` and `storage` nodes plus `typing`/`satisfy` edges, but the app has no renderer for it. The compressor requirements view is a partial `tree` projection. Confirm both through fresh actual-worker captures; do not assume that every requirement-bearing view has the same artifact kind. + +The retained `engine-bridge/opensysml/relationships.go` implements `EvaluateRelationships` for resolved allocation, satisfaction and verification endpoints, including declaration/source/target origins. Its retained Go tests cover aliases, cross-file endpoints, deterministic results and refusal of invalid/incomplete evidence. This code is a useful seam, not fresh execution evidence for this session. The browser semantic worker currently exposes parse and named-view evaluation only. + +The current artifact node schema does not explicitly transport requirement identifiers, documentation or constraints. Native renderer code already supplies a `detail` field combining a short identifier and bounded documentation excerpt, but the TypeScript schema does not type it. Confirm its actual bytes and omissions before choosing separate metadata fields. Prefer the existing `EvaluateView` artifact when it supplies sufficient evidence; expose `EvaluateRelationships` in the browser only if a demonstrated gap requires it. Do not recover relationships or identities with source regexes, infer links from labels, or equate a declared subject with a satisfaction assertion. + +Preserve authored view exposure and filters. The capacity view filters for requirements but its recorded native graph includes `storage`; compare selected membership, artifact nodes and correspondence to understand engine-provided related context. Do not assume all artifact nodes are directly exposed, hide native context without explanation, or synthesize additional nodes ourselves. For truly absent endpoints, show explicitly outside-view context or use a deliberately authored traceability view exposing both endpoint families. Never inject hidden view declarations. + +## Session checkpoints + +Each checkpoint must leave a reviewable, working subset. Record actual commands, outcomes and gaps in verification; keep transient traces under ignored `sandbox/`. + +| Checkpoint | Deliverable | Completion gate | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Prove the engine contract | Retained captures from the actual pinned browser workers for capacity and compressor requirements, resolved satisfaction, nested ownership, aliases and repeated usages | Exact endpoints, origins and complete/invalid/indeterminate states are demonstrated. Identify how view exposure/filter membership and requirement metadata are obtained. Stop the renderer design from assuming missing evidence. | +| 2. Define the projection boundary | Owning TypeBox schemas, normalized requirement data and relationship correspondence; browser transport for the proven read-only operation | Workspace stays independent of React, DOM and engine implementations. Replies bind to the applied revision; malformed, stale, cancelled and partial results cannot replace valid state. Preserve engine pins, checksums and provenance. | +| 3. Render requirements | Dedicated requirement cards and hierarchy/typing notation for the bounded fixture set | Definition versus usage, containment versus typing, identifiers, names and available documentation are distinguishable. Missing metadata has an honest fallback. Existing diagram families still render correctly. | +| 4. Add satisfaction traceability | Declared satisfaction edges with exact endpoint identity and declaration reveal | Direction and labels follow verified engine evidence. Several satisfiers, several requirements and repeated usage names are unambiguous. Hidden endpoints are explained rather than silently dropped or invented. | +| 5. Make browsing usable | Selection, Inspector details, source reveal, keyboard navigation, fit/zoom and bounded layout movement | Browsing changes no source, drafts or semantic history. Switching views/files retains the intended context. Labels and edges remain usable at desktop and narrow widths in light/dark. | +| 6. Verify persistence and failure | Exact save/reopen and compatible geometry; deliberate failure and stale-response scenarios | Existing named-view positions are not borrowed for another representation. Preserve version-1 compatibility where possible; any saved-format change requires an explicit schema/version decision and compatibility fixtures before implementation. Rejected work retains the live project and drafts. | +| 7. Review and integrate | Updated example catalog, capabilities, owning contracts, screenshots and PR to develop | Fast checks, fresh production build, focused real-engine regressions and full visual project pass. Inspect intentional baseline changes. Hosted CI is reported separately. Keep manual deployment as a separate authorized step. | + +Suggested atomic commit boundaries are engine evidence/bridge, validated projection, renderer, satisfaction interactions, preservation regressions and documentation. Combine boundaries when splitting would leave broken transport or callers. Do not publish placeholder UI for an unproven contract. + +## Subagents and worktrees + +Use one coordinator/integration checkout and three bounded coding worktrees. The current checkout holds uncommitted planning edits; this planning step does not commit, push or create coding worktrees. Before implementation, establish an authorized committed planning baseline. After the engine evidence gate, the coordinator publishes the smallest compiling contract checkpoint, and all implementation branches start from that exact commit rather than an independently advancing `develop`. + +| Owner | Branch / sibling worktree suffix | Exclusive implementation ownership | +| ------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Coordinator | `feature/requirements-traceability` / primary checkout | Owning workspace schemas/validation/reconciliation, shared worker request schema, `Diagram.tsx` dispatch and node/edge selection, Workbench/Inspector integration, `ui/theme.css`, shared test helpers, persistence decisions and capability/architecture docs | +| Engine agent | `feature/requirements-engine` / `-requirements-engine` | Retained OpenSysML bridge overlays and their Go tests, `engines/opensysml/` adapter/worker changes, engine preparation/provenance updates only when necessary, engine-specific probe fixtures/captures | +| Renderer agent | `feature/requirements-renderer` / `-requirements-renderer` | New `RequirementNode.tsx`, `RequirementEdge.tsx` only if needed, `requirements.ts`, `requirements.test.ts`, and `ui/requirements.css`; no edits to the shared shell or contract | +| Fixture/test agent | `test/requirements-diagrams` / `-requirements-tests` | New `examples/requirements-traceability/` fixtures, `tests/e2e/requirements.spec.ts`, `requirements-support.ts`, requirements additions to `visual.spec.ts` and new snapshots, example catalog/index updates after real captures | + +Planning audits may inspect the primary checkout read-only. Coding agents must edit only their assigned worktree and paths. Shared-file needs go to the coordinator; an agent must not widen ownership silently. The three older dirty language/recovery/editor-test worktrees are unrelated and remain untouched. + +### Execution waves + +1. **Evidence first.** Engine agent probes the existing capacity and compressor artifacts, native `detail`, selected versus contextual nodes, source correspondence, nested ownership versus native owner-to-type composition, repeated usages, and positive/negated satisfaction. Fixture agent prepares small model inputs and assertion inventories; renderer agent studies notation and dimensions. No UI assumptions are frozen from invented data. +2. **Contract checkpoint.** Coordinator resolves the returned evidence into the smallest validated artifact contract and shares its exact commit/schema. Agree on relation direction, source mapping, optional metadata, incomplete evidence and exposure/context handling. If negated satisfaction cannot be distinguished, do not display it as a positive satisfaction assertion. +3. **Parallel implementation.** Engine, renderer and test agents build their isolated slices against that contract. Controlled renderer/unit fixtures establish UI behavior only; they do not stand in for pinned-engine acceptance. Any changed contract is an explicit coordinator checkpoint that dependent branches consume before continuing. +4. **Integrate incrementally.** Coordinator reviews and integrates engine/contract changes first, renderer files next, then fixtures/tests, wiring shared selection and Inspector paths. Keep atomic compiling commits when commits are authorized. Preserve branch ancestry; no force pushes or overwriting another agent's changes. +5. **Regression and visual review.** Coordinator alone runs production browser builds, focused requirements/preservation checks and baseline generation/comparison. Agents can run isolated unit/type checks. Inspect all intentional screenshots and demonstrate important assertions failing under a bounded fault before restored passing runs. +6. **Close the session.** Update actual capabilities and verification, run the required shared-code regression scope, open one feature PR to `develop` and report hosted checks when push/PR work is authorized. Manual Vercel publication remains separate. Remove only clean merged coding worktrees after preserving their evidence and checking for unpublished work. + +Each agent's handoff must state its base commit and contract version, files changed, executed commands/results, skipped coverage, artifacts, unresolved risks and its proposed integration commit(s). Do not claim successful conformance from a build or screenshot alone. + +### Shared integration decisions + +- Prefer rendering the proven native `requirement` artifact before inventing a new requirement extraction endpoint. Full requirement documentation may need additional engine evidence; a combined abbreviated `detail` string is not structured metadata. +- Ground exact relationship meanings and notation in the formal SysML v2 specification alongside pinned-engine evidence. The imported tutorial series supports the shared definition/usage mental model, not detailed satisfaction/use-case conformance. +- A native `composition` edge can connect an owner to a type while labeling an undrawn owned usage. Do not call that literal ownership of the drawn type; prove hierarchy through resolved membership and edge correspondence. +- Native edge correspondence uses `edge:` while current generic rendered edges use `edge-`. Align selectable identity and source correspondence in coordinator-owned code; never match edges by label. +- Requirement graphs need kind-aware topology validation, not only the existing interconnection validation. Reject duplicate IDs, dangling endpoints and unsupported/ambiguous correspondence without replacing a valid diagram. +- Keep requirement creation/satisfaction editing disabled. The broadly available New part action must not appear to author requirements. +- A tree-to-requirement representation change must not silently borrow incompatible positions. Keep absolute coordinate ownership in the existing presentation flow; any persistence change follows the project-format gate. +- Do not broaden diagram support by treating every tree containing a requirement as a full requirements projection. The compressor view needs its own evidence and an explicit delivered scope. + +### Test/build coordination + +Install dependencies from the committed lockfile per coding worktree using the selected Node runtime and context checks. Do not commit `node_modules`, link it as an untracked source file, or share mutable engine preparation outputs among concurrent builds. Keep each worktree's generated files in its own ignored `sandbox/` and `static/vendor/`. + +The production browser test runner builds the current source and reserves port 4175. Run only one such build/test sequence at a time under the coordinator. The fixture/test agent is the sole writer of `visual.spec.ts` and its requirements snapshots; do not add a separate visual spec without changing project matching deliberately. Add one coherent `@critical` requirements workflow; extended failure cases stay in the full suite. Request Context7 documentation before library-dependent implementation, follow current shadcn conventions, and add no production dependencies without approval. + +## Look and interaction + +Reuse the current shadcn/Radix controls and theme tokens. Requirements should have a recognizable restrained header, a small definition/usage label, prominent declared identifier/name and readable documentation. Use compartments only for metadata actually supplied by the engine. Long text needs deliberate wrapping or an explicit expand action; it must not cover edges or handles. + +Use separate visual treatments and text labels for containment, typing and satisfaction. Do not use color alone. Distinguish contextual satisfying model elements from requirements. Prefer a clear automatic layout and existing fit/zoom controls over introducing a general-purpose routing editor in this slice. + +Selection should reveal requirement details or a relationship's declaration and resolved endpoints. Provide accessible names, visible keyboard focus and focus return for any dialog. Selection is transient; any future create/edit/delete/reconnect action needs the reviewed semantic transaction path. + +## Fixtures and acceptance evidence + +Follow the [example-library workflow](../../examples/README.md). Extend small independent fixtures deliberately rather than treating the large compressor model as the only proof. + +- Capacity example: declared identifier, documentation, requirement definition/usage and the exact storage-to-capacity satisfaction relationship. +- Hierarchy fixture: nested requirement usages and typing, proving that ownership and typing do not collapse into one edge meaning. +- Traceability fixture: multiple requirements/satisfiers, identical display names under different owners, aliases and cross-file references, and negated satisfaction that must never be labeled positive. Author an explicit view exposing the intended nodes. +- Failure fixtures: unknown references, malformed/partial transport, engine asset failure, cancellation and superseded evaluation. Test valid empty relationships separately from unavailable evidence. +- Compressor integration: retain all nine source files, drafts, independent source/view choice and existing structural layouts while browsing its requirements. + +For important new regressions, demonstrate failure under a bounded fault, restore the behavior and rerun. Assertions should establish exact semantic endpoint identity, source location and project preservation, not only node counts or screenshots. Add a focused critical real-engine path for the new supported workflow without promoting every extended matrix case into PR CI. + +Run `npm run check`, `npm run build`, relevant functional browser scenarios and the full visual project. Inspect desktop/narrow and light/dark captures, keyboard access and any dialog focus containment/return. Full semantic regressions are required when shared projection, reconciliation or transaction behavior changes. No skipped assertion, relaxed mismatch threshold or automatic baseline update substitutes for diagnosing a failure. + +## Deferred work + +Verification-case diagrams and result ingestion, coverage dashboards, requirement/satisfaction graphical authoring, automatic declaration-free exploration, new general routing controls, matrices/tables and additional behavior diagram families are separate follow-ups. Verification declarations must never be presented as executed tests. + +This plan does not promise complete SysML requirements notation or language conformance. If the first checkpoint cannot establish identifiers, hierarchy or satisfaction endpoints for a fixture, record the limitation and narrow the delivered subset explicitly rather than infer semantics. diff --git a/docs/next-steps.md b/docs/next-steps.md index 3e7464d..640e1ce 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -1,14 +1,18 @@ # Next steps -Updated 2026-11-01. This is the forward plan; [capabilities](capabilities.md) describes shipped behavior and [verification](verification.md) records actual checks. No delivery dates are assigned. +Updated 2026-11-03. This is the forward plan; [capabilities](capabilities.md) describes shipped behavior and [verification](verification.md) records actual checks. No delivery dates are assigned. ## Current position The editor supports reviewed rename/view/part/connection commands, bounded layout preservation, CodeMirror source editing and draft language queries, issue navigation, resizable panels and explicit browser recovery. The OpenMBEE landing → launcher → workbench flow is implemented. Opening the app does not load an example implicitly. Current diagrams require authored named views. -## 1. Read-only automatic exploration +## 1. Requirements and satisfaction diagrams -Next feature priority: inspect an imported model without adding view declarations. Use a project/package overview when a system subject is ambiguous; offer structural exploration only where the engine demonstrates the intended definition or usage expansion. +The next feature session is [requirements, hierarchy and declared satisfaction](architecture/requirements-views.md), on `feature/requirements-traceability`. Prove the pinned engine metadata, relationship endpoints and view membership first, then deliver a dedicated read-only renderer, source reveal and exact preservation checks. Declared satisfaction is not a test result. Verification coverage and graphical relationship authoring remain separate follow-ups. + +## 2. Read-only automatic exploration + +Separate planned track: inspect an imported model without adding view declarations. Use a project/package overview when a system subject is ambiguous; offer structural exploration only where the engine demonstrates the intended definition or usage expansion. The [engine investigation](research/automatic-exploration-contract.md) is complete for its bounded probes: native `RenderExposed` is promising, but the browser worker currently exposes only named-view evaluation. The tested typed usage did not unfold its definition's internals. Implement and validate that contract before promising a consolidated system diagram. @@ -22,13 +26,13 @@ Acceptance is owned by the [exploration plan](architecture/model-exploration.md) Then add reviewed named-view creation from an exploration and define browsing-context persistence. Portable bundles and recovery do not currently store exploration scope/representation; any format change needs schema/version and compatibility fixtures. -## 2. Local folder workflow +## 3. Local folder workflow Start with explicit import semantics: membership, exclusions, source limits, permission cancellation and unsupported-browser fallback. Existing Open imports copies and Download exports a bundle. Direct file saving is a separate gate: identify written files, validate permission, detect external changes and retain local work on failed or ambiguous writes. Do not label imported copies as folder-backed editing. See [shell capability gates](architecture/application-shell.md#future-opening-workflows). -## 3. Flexo checkout, then reviewed publication +## 4. Flexo checkout, then reviewed publication Follow the [integration research](research/openmbee-flexo-mms.md): prove unchanged interchange and foreign element identity preservation on a pinned deployment, then read an immutable commit selected through project/branch. Identify generated authoring text explicitly. From af5d1f34be88fedb94878c2e29fb8e1d9d3afe8b Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 3 Nov 2026 11:21:28 +0530 Subject: [PATCH 02/13] feat: validate native requirement metadata and relationship semantics --- engine-bridge/README.md | 2 + engine-bridge/opensysml/evaluate_view.go | 6 + engine-bridge/opensysml/requirements_view.go | 108 ++++++++++++++++ .../opensysml/requirements_view_test.go | 96 +++++++++++++++ scripts/prepare-opensysml.mjs | 13 ++ src/lib/workspace/views/index.ts | 67 ++++++++++ src/lib/workspace/views/schema.ts | 12 ++ src/lib/workspace/views/views.test.ts | 116 ++++++++++++++++++ 8 files changed, 420 insertions(+) create mode 100644 engine-bridge/opensysml/requirements_view.go create mode 100644 engine-bridge/opensysml/requirements_view_test.go diff --git a/engine-bridge/README.md b/engine-bridge/README.md index 5e3ba82..2f83332 100644 --- a/engine-bridge/README.md +++ b/engine-bridge/README.md @@ -17,6 +17,8 @@ For an engine upgrade, update explicit pins/checksums, review overlay seams, com The structural bridge retains engine hierarchy, inherited-port correspondence and exact connection endpoints. It adds explicit declaration directions and reference modifiers by recorded origin, without inferring effective directions from types or conjugation. The assembly sources under `examples/assembly/` are copied into the Go bridge test package and included in preparation's input digest. [Structural contract trace and scope](../docs/architecture/structural-views.md). +Native requirement graphs retain the engine's selected subjects and related context. The local overlay adds declared short names, complete owned documentation bodies, satisfaction assertion/negation flags and direct-versus-type-context ownership by looking up recorded AST origins. It does not split rendered detail text, infer satisfaction from subjects, or change the native graph's nodes, edges or order. Requirement transport validation checks exact correspondence, endpoint membership, exposure classification, polarity and direct ownership before publication. A declared relationship is not an execution result. + `insertMember` supports typed part and connection creation. Creation may insert within the recorded owner or promote only its terminal semicolon to a body; existing surrounding source is retained. Toolkit accepts some directionally incompatible connections, so the workspace separately checks explicit out/inout-to-in/inout direction, identical resolved type and exact evaluated usage/port endpoints. Conjugated or ambiguous endpoint paths remain unavailable for graphical creation. [Authoring scope and evidence](../docs/architecture/graphical-authoring.md). Toolkit also supplies read-only completion, hover and definition queries through a separate disposable language worker. Queries use the complete draft source set, negotiate UTF-16 positions and normalize only supported results. The workbench rejects stale replies; completion edits remain unapplied draft changes until the normal source transaction validates them. Engine pins, prepared assets and provenance are unchanged. See [source assistance](../docs/architecture/source-assistance-recovery.md). diff --git a/engine-bridge/opensysml/evaluate_view.go b/engine-bridge/opensysml/evaluate_view.go index 3092319..6e27bfd 100644 --- a/engine-bridge/opensysml/evaluate_view.go +++ b/engine-bridge/opensysml/evaluate_view.go @@ -145,6 +145,12 @@ func (e *Engine) evaluateView(req *JRenderViewRequest) ([]byte, error) { return nil, err } } + if data.Kind == view.KindRequirement { + out.Artifact, err = cached.requirementNotation(out.Artifact, data, out.Selected, declarations) + if err != nil { + return nil, err + } + } out.RenderStatus = "available" contexts := map[string][]string{} for _, node := range data.Nodes { diff --git a/engine-bridge/opensysml/requirements_view.go b/engine-bridge/opensysml/requirements_view.go new file mode 100644 index 0000000..ea5a40a --- /dev/null +++ b/engine-bridge/opensysml/requirements_view.go @@ -0,0 +1,108 @@ +package engine + +import ( + "encoding/json" + + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// requirementNotation enriches the native graph by its recorded origins. It +// does not change the native exposed set, related context, topology or order. +func (m *cachedModel) requirementNotation(raw json.RawMessage, data view.Data, selected []ViewSubject, declarations map[string]*symbols.Symbol) (json.RawMessage, error) { + var base JRenderViewResponse + if err := json.Unmarshal(raw, &base); err != nil { + return nil, err + } + type node struct { + JRenderNode + ShortName string `json:"shortName,omitempty"` + Documentation []string `json:"documentation,omitempty"` + Exposure string `json:"exposure"` + } + type edge struct { + JRenderEdge + Negated *bool `json:"negated,omitempty"` + Asserted *bool `json:"asserted,omitempty"` + Ownership string `json:"ownership,omitempty"` + } + out := struct { + *JRenderViewResponse + Nodes []node `json:"nodes"` + Edges []edge `json:"edges"` + }{JRenderViewResponse: &base, Nodes: []node{}, Edges: []edge{}} + exposed := map[string]bool{} + for _, item := range selected { + exposed[item.ID] = true + } + // Anonymous satisfy/assert declarations are scope members but are not + // registered by a qualified name. Resolve their recorded origins as well. + seen := map[*symbols.Symbol]bool{} + var visit func(*symbols.Symbol) + visit = func(sym *symbols.Symbol) { + if sym == nil || seen[sym] || m.Index.IsLibraryDocument(sym.DocName) { + return + } + seen[sym] = true + key := originKey(sym.Origin()) + if key != "" && declarations[key] == nil { + declarations[key] = sym + } + if sym.Scope != nil { + for _, member := range sym.Scope.AllMembers() { + visit(member) + } + } + } + m.Index.Registered(func(_ string, syms []*symbols.Symbol) { + for _, sym := range syms { + visit(sym) + } + }) + text := m.sourceText() + origins := map[string]string{} + for i, native := range data.Nodes { + origin := originKey(native.Origin) + origins[native.ID] = origin + item := node{JRenderNode: base.Nodes[i], Exposure: "contextual"} + if exposed[origin] { + item.Exposure = "selected" + } + if sym := declarations[origin]; sym != nil { + item.ShortName = sym.ShortName + if sym.Scope != nil && text != nil { + sym.Scope.ForEachMember(func(member *symbols.Symbol) bool { + if doc, ok := member.Decl.(*ast.Documentation); ok { + if body := source.CommentProse(text(member.DocName, doc.BodySpan)); body != "" { + item.Documentation = append(item.Documentation, body) + } + } + return true + }) + } + } + out.Nodes = append(out.Nodes, item) + } + for i, native := range data.Edges { + item := edge{JRenderEdge: base.Edges[i]} + sym := declarations[originKey(native.Origin)] + if sym != nil { + if usage, ok := sym.Decl.(*ast.Usage); ok { + switch item.Kind { + case "satisfy": + negated, asserted := usage.IsNegated, usage.PrefixKeyword == "assert" + item.Negated, item.Asserted = &negated, &asserted + case "composition", "reference": + item.Ownership = "via-type" + if origins[item.To] == originKey(native.Origin) { + item.Ownership = "direct" + } + } + } + } + out.Edges = append(out.Edges, item) + } + return json.Marshal(out) +} diff --git a/engine-bridge/opensysml/requirements_view_test.go b/engine-bridge/opensysml/requirements_view_test.go new file mode 100644 index 0000000..279b512 --- /dev/null +++ b/engine-bridge/opensysml/requirements_view_test.go @@ -0,0 +1,96 @@ +package combined + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestStudioRequirementsNotation(t *testing.T) { + server, err := New("v0.10.0") + if err != nil { + t.Fatal(err) + } + call := func(method string, params any) map[string]any { + t.Helper() + body, _ := json.Marshal(params) + raw, err := server.Call(context.Background(), method, body) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err = json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out + } + prose := "The capacity requirement shall retain its full café documentation even when it exceeds sixty characters." + model := `package P { + part def Device; part a : Device; + requirement def <'REQ-BASE'> Base { doc /* ` + prose + ` */ } + requirement spec { requirement <'REQ-ONE'> one : Base; requirement two : Base; } + satisfy spec.one by a; + assert not satisfy spec.two by a; +} +package V { private import StandardViewDefinitions::*; + view complete : GeneralView { filter @SysML::RequirementDefinition or @SysML::RequirementUsage; expose P::**; } + view compact : GeneralView { filter @SysML::RequirementDefinition or @SysML::RequirementUsage; expose P::Base; expose P::spec; } +}` + parsed := call("ParseSources", map[string]any{"documents": []any{map[string]any{"name": "requirements.sysml", "content": model}}, "strictConformance": true}) + for _, view := range []string{"V::complete", "V::compact"} { + result := call("EvaluateView", map[string]any{"modelHash": parsed["modelHash"], "view": view}) + if result["status"] != "complete" || result["renderStatus"] != "available" { + t.Fatalf("%v", result) + } + artifact := result["artifact"].(map[string]any) + if artifact["kind"] != "requirement" { + t.Fatalf("%v", artifact) + } + names := map[string]string{} + for _, raw := range artifact["nodes"].([]any) { + node := raw.(map[string]any) + names[node["id"].(string)] = node["name"].(string) + if node["name"] == "P::Base" { + if node["shortName"] != "REQ-BASE" || node["documentation"].([]any)[0] != prose { + t.Fatalf("Full own AST metadata missing: %v", node) + } + if !strings.Contains(node["detail"].(string), "...") { + t.Fatalf("Expected native truncated detail separately: %v", node) + } + } + if node["name"] == "P::a" && node["exposure"] != "contextual" { + t.Fatalf("Satisfier falsely selected: %v", node) + } + if strings.HasPrefix(node["name"].(string), "P::") && node["name"] != "P::a" && node["exposure"] != "selected" { + t.Fatalf("Requirement selection lost: %v", node) + } + } + direct, via, positive, negative := false, false, false, false + for _, raw := range artifact["edges"].([]any) { + edge := raw.(map[string]any) + if edge["kind"] == "composition" { + if edge["ownership"] == "direct" { + direct = true + } + if edge["ownership"] == "via-type" { + via = true + } + } + if edge["kind"] == "satisfy" { + switch names[edge["to"].(string)] { + case "P::spec::one": + positive = edge["negated"] == false && edge["asserted"] == false + case "P::spec::two": + negative = edge["negated"] == true && edge["asserted"] == true + } + } + } + if view == "V::complete" && (!direct || via || !positive || !negative) { + t.Fatalf("Lost hierarchy/polarity: %v", artifact) + } + if view == "V::compact" && !via { + t.Fatalf("Hidden member to type was not distinguished: %v", artifact) + } + } +} diff --git a/scripts/prepare-opensysml.mjs b/scripts/prepare-opensysml.mjs index 804e760..b4f1ff6 100644 --- a/scripts/prepare-opensysml.mjs +++ b/scripts/prepare-opensysml.mjs @@ -15,6 +15,8 @@ const overlays = [ 'validated_view_test.go', 'geometry_pose.go', 'geometry_view.go', + 'requirements_view.go', + 'requirements_view_test.go', 'relationships.go', 'relationships_test.go', ]; @@ -95,6 +97,17 @@ for (const [name, folder] of [ name.endsWith('_test.go') ? 'studio_view_test.go' : 'studio_view.go', ), ); +await cp( + resolve(root, 'engine-bridge/opensysml/requirements_view.go'), + resolve(source, 'internal/frontend/engine/studio_requirements_view.go'), +); +await cp( + resolve(root, 'engine-bridge/opensysml/requirements_view_test.go'), + resolve( + source, + 'internal/frontend/combined/studio_requirements_view_test.go', + ), +); await cp( resolve(root, 'engine-bridge/opensysml/relationships.go'), resolve(source, 'internal/frontend/engine/studio_relationships.go'), diff --git a/src/lib/workspace/views/index.ts b/src/lib/workspace/views/index.ts index 46fefaf..0255783 100644 --- a/src/lib/workspace/views/index.ts +++ b/src/lib/workspace/views/index.ts @@ -41,6 +41,7 @@ export function validateEvaluation( throw new Error('View response omitted its artifact.'); if (result.artifact?.kind === 'interconnection') validateStructure(result.artifact); + if (result.artifact?.kind === 'requirement') validateRequirements(result); return result; } @@ -83,3 +84,69 @@ function validateStructure(artifact: ViewArtifact) { } } } + +/** Requirement relationships must carry exact source correspondence, not labels. */ +function validateRequirements(result: EvaluatedView) { + const artifact = result.artifact!; + validateStructure(artifact); + if (!result.selected) + throw new Error('Requirements view omitted its exposed subjects.'); + const selected = new Set(result.selected.map((item) => item.id)); + if (selected.size !== result.selected.length) + throw new Error('Requirements view has duplicate exposed subjects.'); + const correspondence = new Map( + result.correspondence.map((item) => [item.symbol, item]), + ); + if (correspondence.size !== result.correspondence.length) + throw new Error('Requirements view has duplicate correspondence.'); + const subject = (symbol: string) => { + const item = correspondence.get(symbol); + if (!item || !item.subject.origin || !item.subject.id) + throw new Error('Requirements view omitted exact source correspondence.'); + return item.subject; + }; + const ids = new Set(artifact.nodes.map((node) => node.id)); + for (const node of artifact.nodes) { + const source = subject(node.id); + const exposure = selected.has(source.id) ? 'selected' : 'contextual'; + if (node.exposure !== exposure) + throw new Error( + 'Requirements view misrepresented selected versus related context.', + ); + } + const owners = new Map(); + artifact.edges.forEach((edge, index) => { + const id = `edge:${index}`; + if (ids.has(id)) + throw new Error('Requirements edge identity collides with a node.'); + const declaration = subject(id); + if ( + edge.kind === 'satisfy' && + (typeof edge.negated !== 'boolean' || typeof edge.asserted !== 'boolean') + ) + throw new Error('Requirements view omitted satisfaction polarity.'); + if (edge.kind === 'composition' || edge.kind === 'reference') { + if ( + !edge.ownership || + (edge.ownership === 'direct') !== + (declaration.id === subject(edge.to).id) + ) + throw new Error('Requirements view omitted exact ownership semantics.'); + if (edge.ownership === 'direct') { + if (owners.has(edge.to) && owners.get(edge.to) !== edge.from) + throw new Error('Requirement usage has conflicting owners.'); + owners.set(edge.to, edge.from); + } + } + }); + for (const start of owners.keys()) { + const seen = new Set(); + let current: string | undefined = start; + while (current !== undefined) { + if (seen.has(current)) + throw new Error('Requirements view has cyclic ownership.'); + seen.add(current); + current = owners.get(current); + } + } +} diff --git a/src/lib/workspace/views/schema.ts b/src/lib/workspace/views/schema.ts index 96b8649..28d34d6 100644 --- a/src/lib/workspace/views/schema.ts +++ b/src/lib/workspace/views/schema.ts @@ -28,6 +28,12 @@ export const ArtifactNodeSchema = Type.Object({ name: Type.String(), kind: Type.String(), type: Type.Optional(Type.String()), + detail: Type.Optional(Type.String()), + shortName: Type.Optional(Type.String()), + documentation: Type.Optional(Type.Array(Type.String())), + exposure: Type.Optional( + Type.Union([Type.Literal('selected'), Type.Literal('contextual')]), + ), parent: Type.Optional(Type.String()), ports: Type.Optional(Type.Array(ArtifactPortSchema)), reference: Type.Optional(Type.Boolean()), @@ -45,6 +51,11 @@ export const ArtifactEdgeSchema = Type.Object({ ), label: Type.Optional(Type.String()), kind: Type.String(), + negated: Type.Optional(Type.Boolean()), + asserted: Type.Optional(Type.Boolean()), + ownership: Type.Optional( + Type.Union([Type.Literal('direct'), Type.Literal('via-type')]), + ), }); export const EvaluatedViewSchema = Type.Object({ schemaVersion: Type.Literal(1), @@ -62,6 +73,7 @@ export const EvaluatedViewSchema = Type.Object({ ]), diagnostics: Type.Array(Type.String()), correspondence: Type.Array(CorrespondenceSchema), + selected: Type.Optional(Type.Array(ViewSubjectSchema)), artifact: Type.Optional( Type.Object({ kind: Type.String(), diff --git a/src/lib/workspace/views/views.test.ts b/src/lib/workspace/views/views.test.ts index eb79a67..c9ba04e 100644 --- a/src/lib/workspace/views/views.test.ts +++ b/src/lib/workspace/views/views.test.ts @@ -69,3 +69,119 @@ describe('structural artifact validation', () => { expect(() => validateEvaluation(value, 'hash', 'v')).toThrow(); }); }); + +function requirementsResponse() { + const source = (id: string) => ({ + id, + origin: { file: 'requirements.sysml', offset: 0, length: 1 }, + }); + return { + schemaVersion: 1, + modelHash: 'hash', + view: 'v', + status: 'complete', + renderStatus: 'available', + diagnostics: [], + selected: [source('spec'), source('req')], + correspondence: [ + { symbol: 'spec', subject: source('spec'), context: [] }, + { symbol: 'req', subject: source('req'), context: [] }, + { symbol: 'part', subject: source('part'), context: [] }, + { symbol: 'edge:0', subject: source('req'), context: [] }, + { symbol: 'edge:1', subject: source('satisfaction'), context: [] }, + ], + artifact: { + kind: 'requirement', + nodes: [ + { + id: 'spec', + name: 'Specification', + kind: 'requirement', + exposure: 'selected', + }, + { + id: 'req', + name: 'Capacity', + kind: 'requirement', + shortName: 'REQ-1', + documentation: ['Full documentation'], + exposure: 'selected', + }, + { id: 'part', name: 'Tank', kind: 'part', exposure: 'contextual' }, + ], + edges: [ + { from: 'spec', to: 'req', kind: 'composition', ownership: 'direct' }, + { + from: 'part', + to: 'req', + kind: 'satisfy', + negated: true, + asserted: true, + }, + ], + }, + }; +} + +describe('requirements artifact validation', () => { + it('retains exact metadata, selected/contextual subjects and negated satisfaction', () => { + const value = requirementsResponse(); + expect(validateEvaluation(value, 'hash', 'v')).toEqual(value); + }); + it('distinguishes an owned feature drawn through its type from direct ownership', () => { + const value = requirementsResponse(); + value.artifact.edges[0]!.ownership = 'via-type'; + value.correspondence[3]!.subject = { + id: 'undrawn-member', + origin: { file: 'requirements.sysml', offset: 9, length: 1 }, + }; + expect(validateEvaluation(value, 'hash', 'v')).toEqual(value); + }); + it.each([ + 'duplicate node', + 'missing endpoint', + 'missing source', + 'duplicate correspondence', + 'missing polarity', + 'wrong exposure', + 'missing selected', + 'missing ownership', + 'false ownership', + 'cyclic ownership', + 'stale model', + 'partial artifact', + ])('rejects %s without publishing a misleading graph', (fault) => { + const value = requirementsResponse(); + if (fault === 'duplicate node') value.artifact.nodes[2]!.id = 'req'; + if (fault === 'missing endpoint') value.artifact.edges[1]!.from = 'missing'; + if (fault === 'missing source') value.correspondence.pop(); + if (fault === 'duplicate correspondence') + value.correspondence.push(value.correspondence[0]!); + if (fault === 'missing polarity') + delete (value.artifact.edges[1] as { negated?: boolean }).negated; + if (fault === 'wrong exposure') + value.artifact.nodes[2]!.exposure = 'selected'; + if (fault === 'missing selected') + delete (value as { selected?: unknown }).selected; + if (fault === 'missing ownership') + delete value.artifact.edges[0]!.ownership; + if (fault === 'false ownership') + value.artifact.edges[0]!.ownership = 'via-type'; + if (fault === 'cyclic ownership') { + value.artifact.edges.push({ + from: 'req', + to: 'spec', + kind: 'composition', + ownership: 'direct', + }); + value.correspondence.push({ + symbol: 'edge:2', + subject: value.selected[0]!, + context: [], + }); + } + if (fault === 'stale model') value.modelHash = 'old'; + if (fault === 'partial artifact') value.status = 'indeterminate'; + expect(() => validateEvaluation(value, 'hash', 'v')).toThrow(); + }); +}); From cf73d95e2c537d40643a4e2fe27be2c7388047f0 Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 3 Nov 2026 11:22:37 +0530 Subject: [PATCH 03/13] feat: render native requirements and satisfaction graphs --- src/lib/ui/requirements.css | 154 ++++++++++++++++++ .../workbench/diagrams/RequirementNode.tsx | 71 ++++++++ .../workbench/diagrams/requirements.test.ts | 146 +++++++++++++++++ src/lib/workbench/diagrams/requirements.ts | 109 +++++++++++++ 4 files changed, 480 insertions(+) create mode 100644 src/lib/ui/requirements.css create mode 100644 src/lib/workbench/diagrams/RequirementNode.tsx create mode 100644 src/lib/workbench/diagrams/requirements.test.ts create mode 100644 src/lib/workbench/diagrams/requirements.ts diff --git a/src/lib/ui/requirements.css b/src/lib/ui/requirements.css new file mode 100644 index 0000000..5c66ced --- /dev/null +++ b/src/lib/ui/requirements.css @@ -0,0 +1,154 @@ +/* Native requirements graphs; colors express presentation, never verdicts. */ +.requirement-node { + --requirement-heading-bg: var(--sysml-usage-bg); + display: flex; + flex-direction: column; + padding: 0; + text-align: left; +} +.requirement-node.requirement-definition { + --requirement-heading-bg: var(--sysml-definition-bg); +} +.requirement-node.requirement-contextual { + --requirement-heading-bg: var(--sysml-surface); + border-style: dashed; +} +.requirement-heading { + height: 132px; + flex-shrink: 0; + overflow: hidden; + padding: 12px 16px; + background: var(--requirement-heading-bg); + border-bottom: 1px solid var(--sysml-border); + border-radius: var(--sysml-control-radius) var(--sysml-control-radius) 0 0; +} +.requirement-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--sysml-muted); + font-size: 10px; + line-height: 16px; +} +.requirement-meta .model-node-kind { + display: inline-flex; + margin: 0; + padding: 1px 5px; + border-radius: 5px; + background: color-mix(in srgb, var(--sysml-stroke) 8%, transparent); +} +.requirement-identifier { + display: block; + margin-top: 6px; + font-family: var(--sysml-font-mono); + font-size: 11px; + line-height: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.requirement-name { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + overflow-wrap: anywhere; + margin-top: 4px; + font-size: 13px; + font-weight: 650; + line-height: 18px; +} +.requirement-type { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 4px; + color: var(--sysml-muted); + font-size: 11px; + line-height: 16px; +} +.requirement-body { + flex: 1; + min-height: 0; + padding: 12px 16px 8px; + overflow: hidden; +} +.requirement-detail { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; + overflow-wrap: anywhere; + margin: 0; + white-space: pre-line; + font-size: 12px; + line-height: 17px; +} +.requirement-no-detail { + color: var(--sysml-muted); +} +.requirement-inspect { + flex-shrink: 0; + align-self: flex-start; + margin: 0 16px 10px; + height: 24px; + padding: 0; + color: var(--sysml-muted); + font-size: 11px; + text-decoration: underline; + text-underline-offset: 3px; +} +.requirement-inspect:hover { + color: var(--sysml-text); +} +.requirement-inspect:focus-visible { + outline: 2px solid var(--sysml-accent); + outline-offset: 3px; + border-radius: 2px; +} +.react-flow .react-flow__node.requirement-node.selected, +.react-flow .react-flow__node.requirement-node:focus-visible, +.react-flow .react-flow__node.requirement-node:focus-within { + outline: 2px solid var(--sysml-accent); + outline-offset: 2px; + box-shadow: 0 0 0 6px color-mix(in srgb, var(--sysml-accent) 12%, transparent); +} +.requirement-edge .react-flow__edge-path { + stroke: var(--sysml-stroke); + stroke-width: 1.5; +} +.requirement-satisfaction .react-flow__edge-path { + stroke-dasharray: 6 4; +} +.requirement-negated .react-flow__edge-path { + stroke-dasharray: 2 4; +} +.requirement-edge .react-flow__edge-text { + fill: var(--sysml-text); + font-size: 11px; +} +.requirement-edge .react-flow__edge-textbg { + fill: var(--sysml-surface); + fill-opacity: 1; +} +.react-flow .react-flow__edge.requirement-edge.selected .react-flow__edge-path, +.react-flow + .react-flow__edge.requirement-edge:focus-visible + .react-flow__edge-path { + stroke: var(--sysml-accent); + stroke-width: 2.5; +} +@media (forced-colors: active) { + .requirement-node { + border-color: CanvasText; + } + .react-flow .react-flow__node.requirement-node.selected, + .react-flow .react-flow__node.requirement-node:focus-within { + outline-color: Highlight; + } + .requirement-edge .react-flow__edge-path { + stroke: CanvasText; + } +} diff --git a/src/lib/workbench/diagrams/RequirementNode.tsx b/src/lib/workbench/diagrams/RequirementNode.tsx new file mode 100644 index 0000000..226af21 --- /dev/null +++ b/src/lib/workbench/diagrams/RequirementNode.tsx @@ -0,0 +1,71 @@ +import { useContext } from 'react'; +import { Handle, Position, type NodeProps } from '@xyflow/react'; +import { Button } from '$lib/ui/primitives/button'; +import { SelectDiagramSymbol } from './StructureNode'; +import type { RequirementNode as RequirementNodeType } from './requirements'; + +export function RequirementNode({ data }: NodeProps) { + const select = useContext(SelectDiagramSymbol); + const { item } = data; + const documentation = item.documentation?.join('\n\n'); + const detail = documentation || item.detail; + return ( + <> + +
+
+ {item.kind} + {item.exposure === 'contextual' && Related context} +
+ {item.shortName && ( + + {item.shortName} + + )} + + {item.name} + + {item.type && ( + + : {item.type} + + )} +
+
+ {detail ? ( +

+ {detail} +

+ ) : ( +

+ No additional details available. +

+ )} +
+ + + + ); +} diff --git a/src/lib/workbench/diagrams/requirements.test.ts b/src/lib/workbench/diagrams/requirements.test.ts new file mode 100644 index 0000000..bdfb02f --- /dev/null +++ b/src/lib/workbench/diagrams/requirements.test.ts @@ -0,0 +1,146 @@ +import { expect, it } from 'vitest'; +import type { ViewArtifact } from '$lib/workspace/views'; +import { absolutePositions } from './structure'; +import { + layoutRequirements, + requirementEdgeLabel, + requirementHeight, + requirementWidth, +} from './requirements'; + +// Controlled renderer fixtures establish projection behavior, not engine conformance. +const artifact: ViewArtifact = { + kind: 'requirement', + nodes: [ + { + id: 'definition', + name: 'Capacity::Minimum', + kind: 'requirement def', + shortName: 'REQ-1', + documentation: ['The tank shall store at least ten units.'], + exposure: 'selected', + }, + { + id: 'left', + name: 'Capacity::primary::capacity', + kind: 'requirement', + type: 'Capacity::Minimum', + exposure: 'selected', + }, + { + id: 'right', + name: 'Capacity::standby::capacity', + kind: 'requirement', + type: 'Capacity::Minimum', + exposure: 'selected', + }, + { id: 'tank', name: 'System::tank', kind: 'part', exposure: 'contextual' }, + ], + edges: [ + { from: 'left', to: 'definition', kind: 'typing' }, + { from: 'right', to: 'definition', kind: 'typing' }, + { from: 'tank', to: 'left', kind: 'satisfy' }, + { + from: 'tank', + to: 'right', + kind: 'satisfy', + negated: true, + asserted: true, + }, + ], +}; + +it('retains exact native symbol and relationship-index identities with distinct endpoint descriptions', async () => { + const result = await layoutRequirements(artifact, {}); + expect(result.nodes.map((node) => node.id)).toEqual([ + 'definition', + 'left', + 'right', + 'tank', + ]); + expect(result.edges[2]).toMatchObject({ + id: 'edge:2', + source: 'tank', + target: 'left', + label: 'satisfy', + sourceHandle: 'requirement-source', + targetHandle: 'requirement-target', + }); + expect(result.edges[3]).toMatchObject({ + id: 'edge:3', + source: 'tank', + target: 'right', + label: 'assert not satisfy', + }); + expect(result.edges[2]!.ariaLabel).toContain('Capacity::primary::capacity'); + expect(result.edges[3]!.ariaLabel).toContain('Capacity::standby::capacity'); + expect(result.nodes[0]!.ariaLabel).toContain('REQ-1'); + expect(result.nodes[3]!.ariaLabel).toContain('related context'); +}); + +it('keeps display-name duplicates separate and restores absolute saved positions without mutating the artifact', async () => { + const duplicated = { + ...artifact, + nodes: artifact.nodes.map((node) => + node.id === 'left' || node.id === 'right' + ? { ...node, name: 'capacity' } + : node, + ), + }; + const saved = { + left: { x: -143.75, y: 21.125 }, + right: { x: 900.5, y: 1200.25 }, + }; + const before = JSON.stringify(duplicated); + const result = await layoutRequirements(duplicated, saved); + expect( + result.nodes.filter((node) => node.data.item.name === 'capacity'), + ).toHaveLength(2); + expect(absolutePositions(result.nodes)).toMatchObject(saved); + expect(result.nodes.every((node) => node.parentId === undefined)).toBe(true); + expect(result.nodes[0]!.style).toMatchObject({ + width: requirementWidth, + height: requirementHeight, + }); + expect(JSON.stringify(duplicated)).toBe(before); + expect(result.notice).toBe(''); +}); + +it('distinguishes native direct ownership, via-type features, typing and negated satisfaction', () => { + const edge = { from: 'a', to: 'b', kind: 'composition' }; + expect(requirementEdgeLabel({ ...edge, ownership: 'direct' })).toBe( + 'contains', + ); + expect(requirementEdgeLabel({ ...edge, ownership: 'via-type' })).toBe( + 'owned feature (via type)', + ); + expect(requirementEdgeLabel(edge)).toBe('composition'); + expect(requirementEdgeLabel({ ...edge, kind: 'typing' })).toBe('typed by'); + expect( + requirementEdgeLabel({ ...edge, kind: 'satisfy', negated: true }), + ).toBe('not satisfy'); + expect( + requirementEdgeLabel({ ...edge, kind: 'satisfy', asserted: true }), + ).toBe('assert satisfy'); +}); + +it('rejects unresolved and duplicate identities rather than silently discarding relations', async () => { + await expect( + layoutRequirements( + { + ...artifact, + edges: [{ from: 'tank', to: 'missing', kind: 'satisfy' }], + }, + {}, + ), + ).rejects.toThrow('unresolved'); + await expect( + layoutRequirements( + { ...artifact, nodes: [...artifact.nodes, artifact.nodes[0]!] }, + {}, + ), + ).rejects.toThrow('duplicate'); + await expect( + layoutRequirements({ ...artifact, kind: 'tree' }, {}), + ).rejects.toThrow('native requirement'); +}); diff --git a/src/lib/workbench/diagrams/requirements.ts b/src/lib/workbench/diagrams/requirements.ts new file mode 100644 index 0000000..3ae7e8c --- /dev/null +++ b/src/lib/workbench/diagrams/requirements.ts @@ -0,0 +1,109 @@ +import ELK from 'elkjs/lib/elk.bundled.js'; +import { MarkerType, type Edge, type Node } from '@xyflow/react'; +import type { ArtifactNode, Point, ViewArtifact } from '$lib/workspace/views'; + +export const requirementWidth = 340; +export const requirementHeight = 264; +export type RequirementNode = Node<{ item: ArtifactNode }, 'requirement'>; +type Relationship = ViewArtifact['edges'][number]; + +export function requirementNodeLabel(item: ArtifactNode) { + return `${item.kind} ${item.shortName ? item.shortName + ' ' : ''}${item.name}${item.type ? ' : ' + item.type : ''}${item.exposure === 'contextual' ? ', related context' : ''}`; +} + +/** Labels reflect only the native relationship and explicit bridge evidence. */ +export function requirementEdgeLabel(edge: Relationship) { + if (edge.kind === 'satisfy') + return `${edge.asserted ? 'assert ' : ''}${edge.negated ? 'not ' : ''}satisfy`; + if (edge.kind === 'typing') return 'typed by'; + if (edge.kind === 'composition') { + if (edge.ownership === 'via-type') return 'owned feature (via type)'; + if (edge.ownership === 'direct') return 'contains'; + } + if (edge.kind === 'reference' && edge.ownership === 'via-type') + return 'reference (via type)'; + return edge.kind; +} + +/** Flat graph positions cross the workspace boundary as absolute coordinates. */ +export async function layoutRequirements( + artifact: ViewArtifact, + stored: Readonly>, +) { + if (artifact.kind !== 'requirement') + throw new Error('Expected a native requirement graph.'); + const byId = new Map(artifact.nodes.map((item) => [item.id, item])); + if (byId.size !== artifact.nodes.length) + throw new Error('Requirement graph has duplicate symbol identities.'); + for (const edge of artifact.edges) + if (!byId.has(edge.from) || !byId.has(edge.to)) + throw new Error( + 'Requirement graph has an unresolved relationship endpoint.', + ); + const graph = await new ELK().layout({ + id: 'requirements-root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'DOWN', + 'elk.spacing.nodeNode': '88', + 'elk.layered.spacing.nodeNodeBetweenLayers': '104', + }, + children: artifact.nodes.map((item) => ({ + id: item.id, + width: requirementWidth, + height: requirementHeight, + })), + edges: artifact.edges.map((edge, index) => ({ + id: `edge:${index}`, + sources: [edge.from], + targets: [edge.to], + })), + }); + const nodes: RequirementNode[] = artifact.nodes.map((item) => { + const point = + stored[item.id] ?? graph.children?.find((n) => n.id === item.id); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) + throw new Error('Requirement graph has no finite node position.'); + return { + id: item.id, + type: 'requirement', + data: { item }, + position: { x: point.x!, y: point.y! }, + ariaLabel: requirementNodeLabel(item), + className: `requirement-node${item.kind === 'requirement def' ? ' requirement-definition' : ''}${item.exposure === 'contextual' ? ' requirement-contextual' : ''}`, + style: { width: requirementWidth, height: requirementHeight }, + }; + }); + const edges: Edge[] = artifact.edges.map((edge, index) => { + const relationship = requirementEdgeLabel(edge); + const label = + edge.label && edge.label !== edge.kind + ? `${relationship}: ${edge.label}` + : relationship; + return { + id: `edge:${index}`, + source: edge.from, + target: edge.to, + sourceHandle: 'requirement-source', + targetHandle: 'requirement-target', + type: 'smoothstep', + label, + ariaLabel: `${label}: ${requirementNodeLabel(byId.get(edge.from)!)} to ${requirementNodeLabel(byId.get(edge.to)!)}`, + className: `requirement-edge${edge.kind === 'satisfy' ? ' requirement-satisfaction' : ''}${edge.kind === 'typing' ? ' requirement-typing' : ''}${edge.negated ? ' requirement-negated' : ''}`, + ...(edge.kind === 'typing' || edge.kind === 'satisfy' + ? { + markerEnd: { + type: + edge.kind === 'typing' + ? MarkerType.Arrow + : MarkerType.ArrowClosed, + color: 'var(--sysml-stroke)', + }, + } + : {}), + interactionWidth: 24, + deletable: false, + }; + }); + return { nodes, edges, notice: '' }; +} From 5e8e869871a4ab78e39ea168a183b3f9eba19d75 Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 3 Nov 2026 11:23:11 +0530 Subject: [PATCH 04/13] test: add requirement hierarchy and satisfaction fixtures --- examples/README.md | 51 +++++--- examples/catalog.json | 118 ++++++++++++++++++ .../requirements.sysml | 34 +++++ .../requirements-traceability/system.sysml | 18 +++ .../requirements-traceability/views.sysml | 13 ++ tests/e2e/requirements-support.ts | 91 ++++++++++++++ tests/e2e/requirements.spec.ts | 71 +++++++++++ 7 files changed, 381 insertions(+), 15 deletions(-) create mode 100644 examples/requirements-traceability/requirements.sysml create mode 100644 examples/requirements-traceability/system.sysml create mode 100644 examples/requirements-traceability/views.sysml create mode 100644 tests/e2e/requirements-support.ts create mode 100644 tests/e2e/requirements.spec.ts diff --git a/examples/README.md b/examples/README.md index 81c7ca0..7440e36 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,21 +6,22 @@ The “blocks” examples use SysML v2 `part def` and `part` declarations. Lexic Status is per view: **rendered** covers the stated fixture scope; **partial** shows a useful subset with recorded gaps; **unavailable** means the engine evaluates the view but the app reports that its renderer is unavailable. No examples are silently skipped for missing renderers. Targets describe intended visual quality and need visual review; passing source/engine checks alone does not satisfy every target. -| Project | Topics | Named views and current app status | -| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Comments and documentation](comments/model.sysml) | comments, documentation, annotations, unicode, long-labels | `overview`: partial; `maintenance`: partial | -| [Packages and imports](packages/model.sysml) | packages, imports, ownership, multiple-files | `outline`: partial; `dependencies`: unavailable | -| [Parts and definitions (blocks)](parts/model.sysml) | parts, blocks, definitions, usages, specialization, references, repeated-names | `structure`: rendered; `definitions`: partial | -| [Ports and conjugation](ports/model.sysml) | ports, interfaces, connections, conjugation | `link`: partial | -| [Use cases and actors](use-cases/model.sysml) | use-cases, actors, subjects, include, objectives | `scenarios`: unavailable; `definitions`: unavailable | -| [Requirements and satisfaction](requirements/model.sysml) | requirements, documentation, constraints, satisfaction | `requirements`: unavailable | -| [Actions and successions](actions/model.sysml) | actions, behavior, succession, parameters | `process`: unavailable | -| [States and transitions](states/model.sysml) | states, nested-states, transitions, initial-state | `states`: unavailable | -| [Messages and sequence ordering](sequences/model.sysml) | sequences, messages, occurrences, ordering | `exchange`: unavailable | -| [Filtered and empty views](filters/model.sysml) | filters, metadata, recursive-exposure, empty-view | `reviewed`: rendered; `empty`: rendered | -| [Workshop foundation](workshop/model.sysml) | multiple-files, shared-types, rename, tree | `overview`: rendered; `compressor`: rendered | -| [Nested assembly](assembly/model.sysml) | assembly, nested-parts, directed-ports, connections, references, usage-context | `structure`: rendered; `devices`: partial | -| [Smart air workshop](air-compressor/system.sysml) | multiple-files, specialization, directed-ports, conjugation, connections, attributes, constraints, requirements, actions, states, use-cases, sequences, geometry | `overview`: partial; `structure`: partial; `installation`: partial; `pneumatics`: partial; `definitions`: partial; `requirements`: partial; `actions`: unavailable; `maintenance`: unavailable; `useCases`: unavailable; `states`: unavailable; `startupSequence`: unavailable; `demandSequence`: unavailable; `faultSequence`: unavailable; `envelopeGeometry`: unavailable | +| Project | Topics | Named views and current app status | +| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Comments and documentation](comments/model.sysml) | comments, documentation, annotations, unicode, long-labels | `overview`: partial; `maintenance`: partial | +| [Packages and imports](packages/model.sysml) | packages, imports, ownership, multiple-files | `outline`: partial; `dependencies`: unavailable | +| [Parts and definitions (blocks)](parts/model.sysml) | parts, blocks, definitions, usages, specialization, references, repeated-names | `structure`: rendered; `definitions`: partial | +| [Ports and conjugation](ports/model.sysml) | ports, interfaces, connections, conjugation | `link`: partial | +| [Use cases and actors](use-cases/model.sysml) | use-cases, actors, subjects, include, objectives | `scenarios`: unavailable; `definitions`: unavailable | +| [Requirements and satisfaction](requirements/model.sysml) | requirements, documentation, constraints, satisfaction | `requirements`: unavailable | +| [Requirement hierarchy and satisfaction](requirements-traceability/system.sysml) | requirements, hierarchy, typing, satisfaction, negation, aliases, multiple-files, repeated-names | `requirements`: unavailable; `overview`: partial | +| [Actions and successions](actions/model.sysml) | actions, behavior, succession, parameters | `process`: unavailable | +| [States and transitions](states/model.sysml) | states, nested-states, transitions, initial-state | `states`: unavailable | +| [Messages and sequence ordering](sequences/model.sysml) | sequences, messages, occurrences, ordering | `exchange`: unavailable | +| [Filtered and empty views](filters/model.sysml) | filters, metadata, recursive-exposure, empty-view | `reviewed`: rendered; `empty`: rendered | +| [Workshop foundation](workshop/model.sysml) | multiple-files, shared-types, rename, tree | `overview`: rendered; `compressor`: rendered | +| [Nested assembly](assembly/model.sysml) | assembly, nested-parts, directed-ports, connections, references, usage-context | `structure`: rendered; `devices`: partial | +| [Smart air workshop](air-compressor/system.sysml) | multiple-files, specialization, directed-ports, conjugation, connections, attributes, constraints, requirements, actions, states, use-cases, sequences, geometry | `overview`: partial; `structure`: partial; `installation`: partial; `pneumatics`: partial; `definitions`: partial; `requirements`: partial; `actions`: unavailable; `maintenance`: unavailable; `useCases`: unavailable; `states`: unavailable; `startupSequence`: unavailable; `demandSequence`: unavailable; `faultSequence`: unavailable; `envelopeGeometry`: unavailable | ## Visual targets and known gaps @@ -122,6 +123,26 @@ Files: [model.sysml](requirements/model.sysml), [views.sysml](requirements/views Current gaps: The engine emits a requirement graph; the application has no requirement renderer. +### Requirement hierarchy and satisfaction + +Files: [system.sysml](requirements-traceability/system.sysml), [requirements.sysml](requirements-traceability/requirements.sysml), [views.sysml](requirements-traceability/views.sysml). + +**TraceViews::requirements** — engine artifact: `requirement`; app: **unavailable**. + +- Distinguish requirement identifiers, documentation, usage typing and direct ownership. +- Keep the two capacity usages and same-name tank satisfiers separately traceable. +- Render asserted negative satisfaction distinctly; a subject binding alone is not satisfaction. +- Label satisfying parts as engine-supplied related context without claiming executed verification. + +Current gaps: The engine emits a requirement graph; the application has no requirement renderer. + +**TraceViews::overview** — engine artifact: `tree`; app: **partial**. + +- Retain the same source model as an independent generic overview. +- Keep requirement-view and overview positions separate through save and reopen. + +Current gaps: This GeneralView produces a generic flat tree rather than dedicated requirement notation. + ### Actions and successions Files: [model.sysml](actions/model.sysml), [views.sysml](actions/views.sysml). diff --git a/examples/catalog.json b/examples/catalog.json index 1365189..7356e81 100644 --- a/examples/catalog.json +++ b/examples/catalog.json @@ -283,6 +283,124 @@ } ] }, + { + "id": "requirements-traceability", + "title": "Requirement hierarchy and satisfaction", + "topics": [ + "requirements", + "hierarchy", + "typing", + "satisfaction", + "negation", + "aliases", + "multiple-files", + "repeated-names" + ], + "files": ["system.sysml", "requirements.sysml", "views.sysml"], + "views": [ + { + "id": "TraceViews::requirements", + "engineKind": "requirement", + "app": "unavailable", + "nodeNames": [ + "TraceRequirements::MinimumCapacity", + "TraceRequirements::leftSpecification", + "TraceRequirements::rightSpecification", + "TraceRequirements::unlinkedCapacity", + "TraceRequirements::excludedCapacity", + "TraceRequirements::leftSpecification::capacity", + "TraceRequirements::rightSpecification::capacity", + "TraceSystem::spare", + "TraceSystem::LeftPlant::tank", + "TraceSystem::RightPlant::tank" + ], + "edgeKinds": [ + "typing", + "typing", + "typing", + "composition", + "typing", + "composition", + "satisfy", + "satisfy", + "satisfy" + ], + "targets": [ + "Distinguish requirement identifiers, documentation, usage typing and direct ownership.", + "Keep the two capacity usages and same-name tank satisfiers separately traceable.", + "Render asserted negative satisfaction distinctly; a subject binding alone is not satisfaction.", + "Label satisfying parts as engine-supplied related context without claiming executed verification." + ], + "gaps": [ + "The engine emits a requirement graph; the application has no requirement renderer." + ], + "notices": [] + }, + { + "id": "TraceViews::overview", + "engineKind": "tree", + "app": "partial", + "nodeNames": [ + "TraceRequirements", + "MinimumCapacity", + "tank", + "", + "leftSpecification", + "installation", + "capacity", + "tank", + "rightSpecification", + "installation", + "capacity", + "tank", + "unlinkedCapacity", + "tank", + "excludedCapacity", + "tank", + "", + "", + "", + "TraceRequirements::CapacityRule", + "TraceSystem::LeftPlant", + "tank", + "capacity", + "TraceSystem::RightPlant", + "tank", + "capacity", + "TraceSystem::Tank", + "capacity", + "TraceSystem::spare", + "capacity" + ], + "edgeKinds": [ + "typing", + "typing", + "typing", + "typing", + "typing", + "typing", + "typing", + "specialization", + "specialization", + "specialization", + "composition", + "specialization", + "composition", + "specialization", + "typing", + "specialization" + ], + "targets": [ + "Retain the same source model as an independent generic overview.", + "Keep requirement-view and overview positions separate through save and reopen." + ], + "gaps": [ + "This GeneralView produces a generic flat tree rather than dedicated requirement notation." + ], + "notices": [] + } + ] + }, { "id": "actions", "title": "Actions and successions", diff --git a/examples/requirements-traceability/requirements.sysml b/examples/requirements-traceability/requirements.sysml new file mode 100644 index 0000000..7664dcf --- /dev/null +++ b/examples/requirements-traceability/requirements.sysml @@ -0,0 +1,34 @@ +package TraceRequirements { + private import TraceSystem::*; + requirement def <'REQ-CAP'> MinimumCapacity { + doc /* The storage tank shall provide at least ten units of usable + capacity. This declared requirement and its satisfaction links + are model content, not evidence that a test has passed. */ + subject tank : Tank; + require constraint { tank.capacity >= 10.0 } + } + alias CapacityRule for MinimumCapacity; + requirement <'REQ-LEFT'> leftSpecification { + doc /* Capacity requirements for the left installation. */ + subject installation : Tank; + requirement <'REQ-LEFT-CAP'> capacity : CapacityRule { + subject tank = TraceSystem::LeftPlant::tank; + } + } + requirement <'REQ-RIGHT'> rightSpecification { + doc /* Capacity requirements for the right installation. */ + subject installation : Tank; + requirement <'REQ-RIGHT-CAP'> capacity : CapacityRule { + subject tank = TraceSystem::RightPlant::tank; + } + } + requirement <'REQ-UNLINKED'> unlinkedCapacity : MinimumCapacity { + subject tank = TraceSystem::spare; + } + requirement <'REQ-EXCLUDED'> excludedCapacity : MinimumCapacity { + subject tank = TraceSystem::spare; + } + assert not satisfy excludedCapacity by TraceSystem::spare; + satisfy leftSpecification.capacity by TraceSystem::LeftPlant::tank; + satisfy rightSpecification.capacity by TraceSystem::RightPlant::tank; +} diff --git a/examples/requirements-traceability/system.sysml b/examples/requirements-traceability/system.sysml new file mode 100644 index 0000000..7eeaa56 --- /dev/null +++ b/examples/requirements-traceability/system.sysml @@ -0,0 +1,18 @@ +package TraceSystem { + part def Tank { + attribute capacity : ScalarValues::Real; + } + package LeftPlant { + part tank : Tank { + attribute :>> capacity = 20.0; + } + } + package RightPlant { + part tank : Tank { + attribute :>> capacity = 30.0; + } + } + part spare : Tank { + attribute :>> capacity = 40.0; + } +} diff --git a/examples/requirements-traceability/views.sysml b/examples/requirements-traceability/views.sysml new file mode 100644 index 0000000..9c67eab --- /dev/null +++ b/examples/requirements-traceability/views.sysml @@ -0,0 +1,13 @@ +package TraceViews { + private import StandardViewDefinitions::*; + view requirements : GeneralView { + filter @SysML::RequirementDefinition or @SysML::RequirementUsage; + expose TraceRequirements::**; + } + view overview : GeneralView { + expose TraceRequirements::**; + expose TraceSystem::LeftPlant::tank; + expose TraceSystem::RightPlant::tank; + expose TraceSystem::spare; + } +} diff --git a/tests/e2e/requirements-support.ts b/tests/e2e/requirements-support.ts new file mode 100644 index 0000000..a6f5671 --- /dev/null +++ b/tests/e2e/requirements-support.ts @@ -0,0 +1,91 @@ +import { readFile } from 'node:fs/promises'; +import type { Page } from '@playwright/test'; +import type { EvaluatedView } from '../../src/lib/workspace/views'; +import { expect, namedViewButton } from './support'; + +export const requirementViews = { + requirements: 'TraceViews::requirements', + overview: 'TraceViews::overview', +} as const; + +export const expectedSatisfactionPairs = [ + [ + 'TraceSystem::LeftPlant::tank', + 'TraceRequirements::leftSpecification::capacity', + ], + [ + 'TraceSystem::RightPlant::tank', + 'TraceRequirements::rightSpecification::capacity', + ], +] as const; + +export async function loadRequirementsTraceability() { + return Promise.all( + ['system.sysml', 'requirements.sysml', 'views.sysml'].map(async (name) => ({ + name, + text: await readFile( + new URL( + `../../examples/requirements-traceability/${name}`, + import.meta.url, + ), + 'utf8', + ), + })), + ); +} + +export async function importRequirementsTraceability(page: Page) { + const documents = await loadRequirementsTraceability(); + await page.locator('input[type=file]').setInputFiles( + documents.map((document) => ({ + name: document.name, + mimeType: 'text/plain', + buffer: Buffer.from(document.text), + })), + ); + await expect( + namedViewButton(page, requirementViews.requirements), + ).toBeVisible(); + return documents; +} + +/** Resolve actual worker correspondence, never join repeated display labels. */ +export function requirementSymbol( + result: EvaluatedView, + qualifiedName: string, +) { + const nodes = new Set(result.artifact?.nodes.map((node) => node.id)); + const matches = result.correspondence.filter( + (item) => + nodes.has(item.symbol) && item.subject.qualifiedName === qualifiedName, + ); + expect(matches, `Unique occurrence of ${qualifiedName}`).toHaveLength(1); + return matches[0]!; +} + +export function requirementEdgePairs(result: EvaluatedView, kind: string) { + const subjects = new Map( + result.correspondence.map((item) => [ + item.symbol, + item.subject.qualifiedName, + ]), + ); + return (result.artifact?.edges ?? []) + .filter((edge) => edge.kind === kind) + .map((edge) => { + const from = subjects.get(edge.from); + const to = subjects.get(edge.to); + expect( + from, + 'Relationship source has semantic correspondence', + ).toBeTruthy(); + expect( + to, + 'Relationship target has semantic correspondence', + ).toBeTruthy(); + return [from!, to!]; + }) + .sort((left, right) => + JSON.stringify(left).localeCompare(JSON.stringify(right)), + ); +} diff --git a/tests/e2e/requirements.spec.ts b/tests/e2e/requirements.spec.ts new file mode 100644 index 0000000..77cf2f5 --- /dev/null +++ b/tests/e2e/requirements.spec.ts @@ -0,0 +1,71 @@ +import { readFile } from 'node:fs/promises'; +import { + test, + expect, + ready, + observeEvaluations, + namedViewButton, + downloadProject, +} from './support'; +import { + requirementEdgePairs, + requirementSymbol, +} from './requirements-support'; + +// This observes the production workers. It establishes the engine projection, +// independently of the separate UI interaction and preservation scenarios. +test('native capacity requirements retain exact typing and satisfaction correspondence', async ({ + page, +}) => { + const evaluations = await observeEvaluations(page); + await ready(page); + const documents = await Promise.all( + ['model.sysml', 'views.sysml'].map(async (name) => ({ + name, + text: await readFile( + new URL(`../../examples/requirements/${name}`, import.meta.url), + 'utf8', + ), + })), + ); + await page.locator('input[type=file]').setInputFiles( + documents.map((document) => ({ + name: document.name, + mimeType: 'text/plain', + buffer: Buffer.from(document.text), + })), + ); + const view = 'CapacityViews::requirements'; + await expect(namedViewButton(page, view)).toHaveAttribute( + 'aria-current', + 'page', + ); + await expect + .poll(() => evaluations.some((result) => result.view === view)) + .toBe(true); + const result = evaluations.findLast((entry) => entry.view === view)!; + expect(result.status).toBe('complete'); + expect(result.renderStatus).toBe('available'); + expect(result.artifact?.kind).toBe('requirement'); + expect(requirementEdgePairs(result, 'typing')).toEqual([ + [ + 'CapacityRequirements::usableCapacity', + 'CapacityRequirements::MinimumCapacity', + ], + ]); + expect(requirementEdgePairs(result, 'satisfy')).toEqual([ + ['CapacityRequirements::storage', 'CapacityRequirements::usableCapacity'], + ]); + for (const name of [ + 'CapacityRequirements::MinimumCapacity', + 'CapacityRequirements::usableCapacity', + 'CapacityRequirements::storage', + ]) { + const symbol = requirementSymbol(result, name); + expect(symbol.subject.origin?.file).toBe('model.sysml'); + expect(symbol.subject.origin?.length).toBeGreaterThan(0); + } + const saved = (await downloadProject(page)).bundle; + expect(saved.documents).toEqual(documents); + expect(saved.drafts).toEqual(documents); +}); From e90854077725a354b5aac4de76c03fa83846bd80 Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 3 Nov 2026 11:24:28 +0530 Subject: [PATCH 05/13] feat: integrate requirement diagrams and source-bound relationship inspection --- src/lib/ui/theme.css | 1 + src/lib/workbench/diagrams/Diagram.tsx | 117 ++++++++++++----- src/lib/workbench/properties/Properties.tsx | 123 +++++++++++++++--- .../properties/diagram-selection.test.ts | 92 +++++++++++++ .../workbench/properties/diagram-selection.ts | 33 +++++ src/lib/workbench/shell/Workbench.tsx | 14 ++ .../workspace/views/reconciliation.test.ts | 18 +++ src/lib/workspace/views/reconciliation.ts | 4 + 8 files changed, 345 insertions(+), 57 deletions(-) create mode 100644 src/lib/workbench/properties/diagram-selection.test.ts create mode 100644 src/lib/workbench/properties/diagram-selection.ts diff --git a/src/lib/ui/theme.css b/src/lib/ui/theme.css index 621a66d..6ef3c54 100644 --- a/src/lib/ui/theme.css +++ b/src/lib/ui/theme.css @@ -8,3 +8,4 @@ @import './tools.css'; @import './dialogs.css'; @import './entry.css'; +@import './requirements.css'; diff --git a/src/lib/workbench/diagrams/Diagram.tsx b/src/lib/workbench/diagrams/Diagram.tsx index 1cde987..ae5855a 100644 --- a/src/lib/workbench/diagrams/Diagram.tsx +++ b/src/lib/workbench/diagrams/Diagram.tsx @@ -6,6 +6,7 @@ import { ReactFlowProvider, Background, applyNodeChanges, + applyEdgeChanges, useReactFlow, useNodesInitialized, useStore, @@ -21,7 +22,9 @@ import { } from '$lib/ui/primitives/native-select'; import { layoutStructure, absolutePositions } from './structure'; import { StructureNode, SelectDiagramSymbol } from './StructureNode'; -const nodeTypes = { structure: StructureNode }; +import { RequirementNode } from './RequirementNode'; +import { layoutRequirements } from './requirements'; +const nodeTypes = { structure: StructureNode, requirement: RequirementNode }; function FitAvailableSpace({ padding, layoutMode, @@ -106,7 +109,9 @@ export function Diagram({ view?.status === 'complete' && view.renderStatus === 'available' && view.artifact && - ['tree', 'interconnection', 'definition'].includes(view.artifact.kind); + ['tree', 'interconnection', 'definition', 'requirement'].includes( + view.artifact.kind, + ); const [initialPresentation] = useState(presentation); useEffect(() => { const artifact = view?.artifact; @@ -135,8 +140,13 @@ export function Diagram({ initialPresentation?.modelHash === view?.modelHash ? initialPresentation.positions : {}; - if (artifact.kind === 'interconnection') { - void layoutStructure(artifact, stored) + if ( + artifact.kind === 'interconnection' || + artifact.kind === 'requirement' + ) { + const layout = + artifact.kind === 'requirement' ? layoutRequirements : layoutStructure; + void layout(artifact, stored) .then((result) => { if (cancelled) return; setNodes(result.nodes); @@ -268,9 +278,11 @@ export function Diagram({ {view.artifact.kind === 'interconnection' ? 'Structure' - : view.artifact.kind === 'tree' - ? 'Overview tree' - : 'Named view'} + : view.artifact.kind === 'requirement' + ? 'Requirements' + : view.artifact.kind === 'tree' + ? 'Overview tree' + : 'Named view'} )} @@ -282,24 +294,26 @@ export function Diagram({ -
- - -
+ {view?.artifact?.kind !== 'requirement' && ( +
+ + +
+ )} {!view ? (
@@ -335,9 +349,39 @@ export function Diagram({ nodes={nodes} edges={edges} nodeTypes={nodeTypes} - onNodesChange={(changes) => - setNodes((current) => applyNodeChanges(changes, current)) - } + onNodesChange={(changes) => { + setNodes((current) => applyNodeChanges(changes, current)); + if (view?.artifact?.kind === 'requirement') { + for (const change of changes) + if (change.type === 'select' && change.selected) + onSelect(change.id); + if ( + !disabled && + !locked && + changes.some( + (change) => + change.type === 'position' && + change.position && + change.dragging === false, + ) + ) + onMove( + absolutePositions(applyNodeChanges(changes, nodes)), + ); + } + }} + onEdgesChange={(changes) => { + setEdges((current) => applyEdgeChanges(changes, current)); + if (view?.artifact?.kind === 'requirement') { + for (const change of changes) + if (change.type === 'select' && change.selected) + onSelect(change.id); + } + }} + edgesFocusable={!locked} + onEdgeClick={(_event, edge) => { + if (view?.artifact?.kind === 'requirement') onSelect(edge.id); + }} fitView fitViewOptions={{ padding: 0.2, maxZoom: 1 }} nodesConnectable={false} @@ -345,15 +389,16 @@ export function Diagram({ elementsSelectable={!locked} deleteKeyCode={null} onNodeClick={(_event, node) => onSelect(node.id)} - onNodeDragStop={(_event, node) => - onMove( - absolutePositions( - nodes.map((current) => - current.id === node.id ? node : current, + onNodeDragStop={(_event, node) => { + if (view?.artifact?.kind !== 'requirement') + onMove( + absolutePositions( + nodes.map((current) => + current.id === node.id ? node : current, + ), ), - ), - ) - } + ); + }} > void; @@ -25,7 +35,51 @@ export function Properties({ return (
-
- {detail ? ( -

- {detail} -

- ) : ( -

- No additional details available. -

- )} -
+ {isRequirement(item) && ( +
+ {detail ? ( +

+ {detail} +

+ ) : ( +

+ No additional details available. +

+ )} +
+ )}