From 4c03adbbcbe7e82fa18c062bfccb6e816d51804c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sun, 5 Jul 2026 23:57:31 -0400 Subject: [PATCH 1/2] feat(render): render all declared views by default when --view is not specified Previously the render command errored out when a workspace contained multiple declared SysML view elements and --view was not specified. This change makes rendering every declared view (one output file per view) the default 'render everything' behavior, supporting bulk-export workflows such as CI diagram galleries or design-doc publishing. --view still narrows the render to a single named view, but now only reports an error when the named view does not exist in the workspace (listing the available view names). - Update RenderCommand.RunAsync to render all views by default and only error on an unknown --view name. - Replace requirement SysML2Tools-Tool-Render-MultipleViewError with SysML2Tools-Tool-Render-AllViewsExport and SysML2Tools-Tool-Render-UnknownViewError; reword ViewSelection justification. - Update design doc (render.md) step 5 / Error Handling / traceability table to match the new control flow. - Update verification doc acceptance criteria and scenarios. - Replace obsolete tests with RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews and RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews. - Update README.md and docs/user_guide/introduction.md View Selection tables and --view option description. - Update RenderStrings.resx CLI help text for --view. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 7 +-- docs/design/sysml2-tools-tool/render.md | 15 ++++-- docs/reqstream/sysml2-tools-tool/render.yaml | 28 +++++++---- docs/user_guide/introduction.md | 5 +- docs/verification/sysml2-tools-tool/render.md | 16 ++++--- .../Render/RenderCommand.cs | 8 ++-- .../Render/RenderStrings.resx | 2 +- .../Render/RenderSubsystemTests.cs | 48 +++++++++++-------- 8 files changed, 81 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index aac1f2b0..b046e2ff 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ sysml2tools help [lint|render|query []] | --- | --- | | `` | One or more glob patterns for `.sysml` input files | | `--output ` | Output file path; extension determines format (`.svg` or `.png`) | -| `--view ` | Name of the view to render (required when workspace has multiple views) | +| `--view ` | Name of the view to render; omit to render every declared view (default) | | `--auto` | Auto-render the BDD of the top-level `part def` when no view is defined | | `--depth <#>` | Limit rendered nesting depth; truncated parts show `+N more…` | @@ -219,8 +219,9 @@ sysml2tools help [lint|render|query []] | Exactly one view in workspace | Render it | | Zero views, `--auto` specified | Auto-render BDD of top-level `part def` silently | | Zero views, no `--auto` | Warn and auto-render | -| Multiple views, none specified | Error: lists available view names and exits non-zero | -| Multiple views, `--view ` | Render the named view | +| Multiple views, none specified | Render every declared view (one output file per view) | +| Multiple views, `--view ` | Render only the named view | +| `--view ` names a view that does not exist | Error: lists available view names and exits non-zero | ## NuGet Packages diff --git a/docs/design/sysml2-tools-tool/render.md b/docs/design/sysml2-tools-tool/render.md index ef0ae9a8..4cc7dc3a 100644 --- a/docs/design/sysml2-tools-tool/render.md +++ b/docs/design/sysml2-tools-tool/render.md @@ -41,8 +41,11 @@ Entry point for the render command. Steps: 3. Reports all diagnostics from `loadResult.Diagnostics`, writing errors via `context.WriteError` and other messages via `context.WriteLine`. 4. Calls `DiagramRenderer.GetViewNames(workspace)` to enumerate renderable views. -5. When `viewNames.Count > 1` and `options.ViewName` is null, calls `context.WriteError` - with a message listing the available names and returns early. +5. Calls `DiagramRenderer.GetViewNames(loadResult.Workspace)` again (via the same call at step + 4) to validate `options.ViewName` when supplied: when `options.ViewName` is not null and does + not match any declared view name, calls `context.WriteError` with a message listing the + available view names and returns early. When `options.ViewName` is null, no validation is + performed here — every declared view will be rendered in step 7. 6. Resolves `format = options.Format ?? "svg"` and eagerly rejects any value other than `"svg"`/`"png"` (case-insensitive) with `ArgumentException` naming the bad value — mirroring the `query` command's `--format` validation style. This is validated here, in `RunAsync`, not @@ -71,8 +74,9 @@ future-locale story, which applies identically here. - Missing file patterns: `context.WriteError` is called and the method returns early. - Load diagnostics: reported to the context; non-fatal; rendering proceeds regardless. -- Multiple views without `--view`: `context.WriteError` lists available view names and - returns early. +- Multiple views without `--view`: no error; every declared view is rendered (one output file + per view), supporting bulk "render everything" exports. +- Unknown `--view` name: `context.WriteError` lists available view names and returns early. - Unsupported `--format` value: `ArgumentException` is thrown naming the bad value and the valid values (`svg`, `png`); propagates to `Program.Main`'s expected-exception handler. - No view declarations: informational message; no output files written; returns normally. @@ -104,6 +108,7 @@ future-locale story, which applies identically here. | SysML2Tools-Tool-Render-Output | Output directory resolution in `RunAsync` | | SysML2Tools-Tool-Render-Empty | Empty-outputs message in `RunAsync` | | SysML2Tools-Tool-Render-DepthLimit | `DepthLimit` passed to `RenderOptions` in `RunAsync` | -| SysML2Tools-Tool-Render-MultipleViewError | Multi-view guard using `GetViewNames` in `RunAsync` | +| SysML2Tools-Tool-Render-AllViewsExport | Default render-all-views logic using `viewNames` in `RunAsync` | +| SysML2Tools-Tool-Render-UnknownViewError | Unknown `--view` name guard using `viewNames` in `RunAsync` | | SysML2Tools-Tool-Render-ViewSelection | `viewFilter` passed to `RenderWorkspace` in `RunAsync` | | SysML2Tools-Tool-Render-FormatValidation | Eager `--format` value guard in `RunAsync` | diff --git a/docs/reqstream/sysml2-tools-tool/render.yaml b/docs/reqstream/sysml2-tools-tool/render.yaml index 0740cee9..1fa50f3a 100644 --- a/docs/reqstream/sysml2-tools-tool/render.yaml +++ b/docs/reqstream/sysml2-tools-tool/render.yaml @@ -71,25 +71,35 @@ sections: tests: - RenderSubsystem_WithDepth_LimitsNesting - - id: SysML2Tools-Tool-Render-MultipleViewError + - id: SysML2Tools-Tool-Render-AllViewsExport + title: >- + The render command shall render every declared view in the workspace, producing + one output file per view, when --view is not specified. + justification: | + Rendering every declared view by default supports bulk "render everything" + exports for CI pipelines and design-doc publishing without requiring the user + to invoke the command once per view. + tests: + - RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews + + - id: SysML2Tools-Tool-Render-UnknownViewError title: >- The render command shall report an error listing all available view names when - the workspace contains multiple renderable views and --view is not specified. + --view specifies a view name that does not exist in the workspace. justification: | - Requiring explicit view selection prevents rendering the wrong diagram when - a model contains multiple views, and the error lists choices so the user knows - what to supply. + Reporting an error with the list of available view names gives users an + actionable message when they mistype or misremember a view's display name. tests: - - RenderSubsystem_MultipleViews_NoViewFlag_ReportsError - - RenderSubsystem_MultipleViews_NoViewFlag_ListsAvailableViews + - RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews - id: SysML2Tools-Tool-Render-ViewSelection title: >- The render command shall render only the view whose display name matches the - value supplied via --view. + value supplied via --view, when --view is specified. justification: | View selection enables targeting a single diagram for output in multi-view - models without generating every diagram in the workspace. + models without generating every diagram in the workspace; it is always + optional, narrowing the default render-all behavior to one view on request. tests: - RenderSubsystem_MultipleViews_WithViewFlag_RendersSelectedView diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 5cee0bf1..9976dbdf 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -103,8 +103,9 @@ sysml2tools render model.sysml --auto --output diagram.svg | Exactly one view in workspace | Render it | | Zero views, `--auto` specified | Auto-render BDD of top-level `part def` silently | | Zero views, no `--auto` | Warn: "define a view or use --auto", then auto-render | -| Multiple views, none specified | Error: lists available view names, exits non-zero | -| Multiple views, `--view ` | Render the named view | +| Multiple views, none specified | Render every declared view (one output file per view) | +| Multiple views, `--view ` | Render only the named view | +| `--view ` names a view that does not exist | Error: lists available view names, exits non-zero | ## Depth Limiting diff --git a/docs/verification/sysml2-tools-tool/render.md b/docs/verification/sysml2-tools-tool/render.md index dac9b8a4..346db1b9 100644 --- a/docs/verification/sysml2-tools-tool/render.md +++ b/docs/verification/sysml2-tools-tool/render.md @@ -25,8 +25,11 @@ on context output and exit code. File-writing scenarios use a temporary director - No output files written when workspace has no views - Informational message written when workspace has no views - `--depth 1` produces SVG output containing the ellipsis character `"…"` -- Multiple views without `--view` yields exit code 1 and an error message +- Multiple views without `--view` renders every declared view, producing one output file per + view, exit code 0 - `--view ` with a multi-view workspace renders exactly one file +- `--view ` naming a view that does not exist yields exit code 1 and an error message + listing available view names - Unsupported `--format` value throws `ArgumentException` when `RunAsync` executes (not at `Context.Create` parse time) - `render --help` prints render-specific usage and options (not the generic top-level command @@ -70,14 +73,15 @@ informational message. Verifies that `--depth 1` causes the SVG output to contain the ellipsis character `"…"`, confirming that child part-def boxes were replaced by the depth-limit indicator. -##### RenderSubsystem_MultipleViews_NoViewFlag_ReportsError +##### RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews -Verifies that rendering a workspace with two views and no `--view` flag yields exit code 1. +Verifies that rendering a workspace with two views and no `--view` flag renders every +declared view: exit code 0, and exactly two `.svg` output files produced (one per view). -##### RenderSubsystem_MultipleViews_NoViewFlag_ListsAvailableViews +##### RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews -Verifies that the multi-view error path writes an error containing the available view names -to the log. +Verifies that `--view ` yields exit code 1 and an error message listing +the available view names (`ViewAlpha` and `ViewBeta`). ##### RenderSubsystem_MultipleViews_WithViewFlag_RendersSelectedView diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs index acc6c309..6c0c536e 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs @@ -68,13 +68,15 @@ public static async Task RunAsync(Context context) return; } - // Enumerate renderable views; require --view when multiple views are present + // Enumerate renderable views. By default (no --view) every declared view is rendered, + // supporting bulk "render everything" exports for CI/design-doc publishing; --view narrows + // the run to a single named view. var viewNames = DiagramRenderer.GetViewNames(loadResult.Workspace); - if (viewNames.Count > 1 && options.ViewName is null) + if (options.ViewName is not null && !viewNames.Contains(options.ViewName, StringComparer.Ordinal)) { var available = string.Join(", ", viewNames); context.WriteError( - $"error: multiple views found; use --view to select one (available: {available})"); + $"error: view '{options.ViewName}' not found; use --view to select one (available: {available})"); return; } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx index 89c8bd10..38056261 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx @@ -74,7 +74,7 @@ --format svg|png Renderer format (default: svg) - --view <name> Select a specific view to render, when multiple are defined + --view <name> Select a specific view to render, instead of rendering all views --auto Auto-generate a view when none are defined diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs index 4a391aaf..ab3f0e2c 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs @@ -331,11 +331,11 @@ view def ViewBeta {} """; /// - /// RenderCommand reports an error when the workspace contains multiple views and - /// --view is not specified. + /// RenderCommand renders every declared view when the workspace contains multiple + /// views and --view is not specified, producing one output file per view. /// [Fact] - public async Task RenderSubsystem_MultipleViews_NoViewFlag_ReportsError() + public async Task RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews() { // Arrange: write a SysML model with two views var tempDir = Path.Combine(Path.GetTempPath(), $"render_multi_{Guid.NewGuid():N}"); @@ -344,35 +344,37 @@ public async Task RenderSubsystem_MultipleViews_NoViewFlag_ReportsError() await File.WriteAllTextAsync(tempFile, SysmlWithTwoViews, TestContext.Current.CancellationToken); var outputDir = Path.Combine(tempDir, "out"); - var originalError = Console.Error; + var originalOut = Console.Out; try { - using var errWriter = new StringWriter(); - Console.SetError(errWriter); + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); // Act: render without --view flag using var context = Context.Create(["render", "--output", outputDir, tempFile]); await Program.RunAsync(context); - // Assert: exit code indicates failure; error was reported - Assert.Equal(1, context.ExitCode); + // Assert: exit code indicates success; exactly two .svg files were produced + Assert.Equal(0, context.ExitCode); + var svgFiles = Directory.GetFiles(outputDir, "*.svg"); + Assert.Equal(2, svgFiles.Length); } finally { - Console.SetError(originalError); + Console.SetOut(originalOut); Directory.Delete(tempDir, recursive: true); } } /// - /// RenderCommand error output lists available view names when multiple views are - /// present and --view is not specified. + /// RenderCommand reports an error listing available view names when --view names a + /// view that does not exist in the workspace. /// [Fact] - public async Task RenderSubsystem_MultipleViews_NoViewFlag_ListsAvailableViews() + public async Task RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews() { // Arrange: write a SysML model with two named views - var tempDir = Path.Combine(Path.GetTempPath(), $"render_multi_views_{Guid.NewGuid():N}"); + var tempDir = Path.Combine(Path.GetTempPath(), $"render_unknown_view_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var tempFile = Path.Combine(tempDir, "model.sysml"); await File.WriteAllTextAsync(tempFile, SysmlWithTwoViews, TestContext.Current.CancellationToken); @@ -384,14 +386,22 @@ public async Task RenderSubsystem_MultipleViews_NoViewFlag_ListsAvailableViews() using var errWriter = new StringWriter(); Console.SetError(errWriter); - // Act: render without --view flag; use log to capture output for assertion + // Act: render with --view naming a view that does not exist; use log to capture + // output for assertion var logFile = Path.Combine(tempDir, "output.log"); - using var context = Context.Create( - ["render", "--silent", "--log", logFile, "--output", outputDir, tempFile]); - await Program.RunAsync(context); + using (var context = Context.Create( + ["render", "--silent", "--log", logFile, "--view", "NoSuchView", "--output", outputDir, tempFile])) + { + await Program.RunAsync(context); - // Assert: exit code indicates failure; error message in log lists view names - Assert.Equal(1, context.ExitCode); + // Assert: exit code indicates failure + Assert.Equal(1, context.ExitCode); + } + + // Assert: log content lists available view names + var logContent = await File.ReadAllTextAsync(logFile, TestContext.Current.CancellationToken); + Assert.Contains("ViewAlpha", logContent); + Assert.Contains("ViewBeta", logContent); } finally { From 20ccc3cf7539d420ef3be23447750cb2c1412fdb Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 6 Jul 2026 00:37:01 -0400 Subject: [PATCH 2/2] fix(render): prevent silent filename collisions and correct documentation Addresses code-review and formal-review findings against the render-all-views-by-default change: - RenderCommand now detects duplicate output filenames when rendering all views (--view not specified) and aborts with a clear error listing the colliding qualified view names and shared filename, before writing any files. Previously two views with the same simple name in different packages would silently overwrite each other. - Add DiagramRenderer.GetViewIdentities to expose qualified names alongside display names; refactor GetViewNames to reuse it. - Add SysML2Tools-Tool-Render-FileNameCollision requirement, linked to a new RenderSubsystem_DuplicateViewFileNames_ReportsCollisionError test. - Strengthen RenderSubsystem_NoFiles_ReportsError to assert the actual diagnostic text, not just the exit code. - Fix docs/verification/sysml2-tools-tool/render.md heading depth (### -> ##) to match sibling subsystem verification docs. - Add missing StdlibProvider dependency and stdlib-seeding step to docs/design/sysml2-tools-tool/render.md. - Update docs/design/sysml2-tools-tool.md architecture overview to list all seven subsystems and add Render/Help to the Mermaid diagram; add missing Query/Render folder entries to docs/design/introduction.md. - Correct README.md and docs/user_guide/introduction.md: --output is a directory (format chosen via --format), the zero-views/no --auto case only prints an informational message and writes no files, and replace non-PDF-safe anchor links with verbal section references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 24 +++--- docs/design/introduction.md | 3 + docs/design/sysml2-tools-tool.md | 23 ++++-- docs/design/sysml2-tools-tool/render.md | 16 +++- docs/reqstream/sysml2-tools-tool/render.yaml | 14 ++++ docs/user_guide/introduction.md | 17 +++-- docs/verification/sysml2-tools-tool/render.md | 55 +++++++++----- .../Rendering/DiagramRenderer.cs | 45 +++++++++-- .../Render/RenderCommand.cs | 76 +++++++++++++++++++ .../Render/RenderSubsystemTests.cs | 58 +++++++++++++- 10 files changed, 276 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index b046e2ff..04513486 100644 --- a/README.md +++ b/README.md @@ -73,23 +73,24 @@ Exit code is non-zero if any errors are present, making it suitable for CI/CD pi ### Rendering -Render a SysML v2 workspace to SVG or PNG: +Render a SysML v2 workspace to SVG or PNG. `--output` names an output *directory* +(default: current directory); `--format` selects `svg` (default) or `png`: ```bash # Render to SVG (auto-selects the single view in the workspace) -sysml2tools render model.sysml --output diagram.svg +sysml2tools render model.sysml --output out --format svg # Render to PNG -sysml2tools render model.sysml --output diagram.png +sysml2tools render model.sysml --output out --format png # Render a named view from a multi-view workspace -sysml2tools render "src/**/*.sysml" --view SystemContext --output context.svg +sysml2tools render "src/**/*.sysml" --view SystemContext --output out --format svg # Auto-render the top-level part def when no view is defined -sysml2tools render model.sysml --auto --output diagram.svg +sysml2tools render model.sysml --auto --output out --format svg # Limit nesting depth (truncated parts show "+N more…") -sysml2tools render model.sysml --output diagram.svg --depth 3 +sysml2tools render model.sysml --output out --depth 3 ``` ### Querying @@ -160,8 +161,8 @@ sysml2tools [-v|--version] [-?|-h|--help] [--silent] sysml2tools help [lint|render|query []] ``` -`` is `lint`, `render`, or `query ` (11 query verbs — see -[Querying](#querying)). +`` is `lint`, `render`, or `query ` (11 query verbs — see the *Querying* +section above). ### Global Options @@ -186,7 +187,8 @@ sysml2tools help [lint|render|query []] | Option | Description | | --- | --- | | `` | One or more glob patterns for `.sysml` input files | -| `--output ` | Output file path; extension determines format (`.svg` or `.png`) | +| `--output ` | Output directory for rendered files (default: current directory) | +| `--format svg\|png` | Renderer format (default: `svg`) | | `--view ` | Name of the view to render; omit to render every declared view (default) | | `--auto` | Auto-render the BDD of the top-level `part def` when no view is defined | | `--depth <#>` | Limit rendered nesting depth; truncated parts show `+N more…` | @@ -195,7 +197,7 @@ sysml2tools help [lint|render|query []] | Option | Description | | --- | --- | -| `` | One of the 11 supported query verbs — see [Querying](#querying) | +| `` | One of the 11 supported query verbs — see the *Querying* section above | | `` | One or more glob patterns for `.sysml` input files | | `--element `, `-e ` | Qualified name of the target element; required for every verb except `list`/`find` | | `--format markdown\|json` | Output format (default: `markdown`); distinct from `render`'s `--format` (`svg`/`png`) | @@ -218,7 +220,7 @@ sysml2tools help [lint|render|query []] | --- | --- | | Exactly one view in workspace | Render it | | Zero views, `--auto` specified | Auto-render BDD of top-level `part def` silently | -| Zero views, no `--auto` | Warn and auto-render | +| Zero views, no `--auto` | Informational message; no output files written | | Multiple views, none specified | Render every declared view (one output file per view) | | Multiple views, `--view ` | Render only the named view | | `--view ` names a view that does not exist | Error: lists available view names and exits non-zero | diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 75cbc101..5b220f3b 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -159,7 +159,9 @@ reviewers an explicit navigation aid from design to code: - **DemaConsulting.SysML2Tools.Tool/** — dotnet tool CLI wrapper - **Cli/** — command-line interface subsystem - **Lint/** — lint command subsystem + - **Render/** — render command subsystem - **Help/** — help command subsystem + - **Query/** — query command subsystem - **SelfTest/** — self-validation subsystem - **Utilities/** — shared utilities subsystem - **Tools/StdlibGen/** — build-time stdlib pre-compiler tool @@ -172,6 +174,7 @@ reviewers an explicit navigation aid from design to code: - **lint/** — Lint subsystem design - **render/** — Render subsystem design (render.md) - **help.md** — Help subsystem design + - **query.md** — Query subsystem design - **self-test/** — SelfTest subsystem design - **utilities/** — Utilities subsystem design diff --git a/docs/design/sysml2-tools-tool.md b/docs/design/sysml2-tools-tool.md index 8b91a2ff..14d0fdf4 100644 --- a/docs/design/sysml2-tools-tool.md +++ b/docs/design/sysml2-tools-tool.md @@ -3,8 +3,8 @@ ## Architecture The `DemaConsulting.SysML2Tools.Tool` is a command-line application built on .NET. It is structured as one -system containing one top-level unit (`Program`) and three subsystems (`Cli`, `SelfTest`, -`Utilities`): +system containing one top-level unit (`Program`) and seven subsystems (`Cli`, `Lint`, `Render`, +`Help`, `Query`, `SelfTest`, `Utilities`): ```mermaid flowchart TD @@ -15,6 +15,12 @@ flowchart TD subgraph Lint LintCommand end + subgraph Render + RenderCommand + end + subgraph Help + HelpCommand + end subgraph Query QueryCommand end @@ -26,6 +32,8 @@ flowchart TD end Program --> Context Program --> LintCommand + Program --> RenderCommand + Program --> HelpCommand Program --> QueryCommand Program --> Validation Validation --> Program @@ -33,11 +41,12 @@ flowchart TD ``` `Program` is the entry point. It creates a `Context` from the `Cli` subsystem, dispatches to -`LintCommand` when the `lint` subcommand is passed, dispatches to `QueryCommand` when the -`query` subcommand is passed, dispatches to `Validation` when `--validate` is passed, and -returns the exit code from `Context`. `Validation` calls `Program.Run` recursively -to exercise the tool during self-testing, and uses `PathHelpers` to construct safe temporary file -paths. +`LintCommand` when the `lint` subcommand is passed, dispatches to `RenderCommand` when the +`render` subcommand is passed, dispatches to `HelpCommand` when the `help` subcommand (or +`--help`) is passed, dispatches to `QueryCommand` when the `query` subcommand is passed, +dispatches to `Validation` when `--validate` is passed, and returns the exit code from +`Context`. `Validation` calls `Program.Run` recursively to exercise the tool during +self-testing, and uses `PathHelpers` to construct safe temporary file paths. ## External Interfaces diff --git a/docs/design/sysml2-tools-tool/render.md b/docs/design/sysml2-tools-tool/render.md index 4cc7dc3a..1bc1e16c 100644 --- a/docs/design/sysml2-tools-tool/render.md +++ b/docs/design/sysml2-tools-tool/render.md @@ -37,7 +37,9 @@ Entry point for the render command. Steps: `Program` only reaches here when `Context.Create` has already populated it) and validates that `options.Files` is non-empty; calls `context.WriteError` and returns when no patterns are supplied. -2. Calls `WorkspaceLoader.LoadAsync(options.Files)` to load the workspace. +2. Calls `StdlibProvider.GetSymbolTable()` to obtain the pre-resolved OMG stdlib symbol table, + then calls `WorkspaceLoader.LoadAsync(options.Files, stdlibTable)` to load the workspace, + seeded with the stdlib symbol table so stdlib elements resolve without re-parsing them. 3. Reports all diagnostics from `loadResult.Diagnostics`, writing errors via `context.WriteError` and other messages via `context.WriteLine`. 4. Calls `DiagramRenderer.GetViewNames(workspace)` to enumerate renderable views. @@ -76,6 +78,11 @@ future-locale story, which applies identically here. - Load diagnostics: reported to the context; non-fatal; rendering proceeds regardless. - Multiple views without `--view`: no error; every declared view is rendered (one output file per view), supporting bulk "render everything" exports. +- Output file name collision: when rendering all views (`--view` not specified) with more than + one output, and two or more views' sanitized display names produce the same output file + name, `context.WriteError` reports every colliding group (listing the colliding qualified + view names and the shared file name) and the method returns before any file is written for + this run, rather than silently overwriting one view's output with another's. - Unknown `--view` name: `context.WriteError` lists available view names and returns early. - Unsupported `--format` value: `ArgumentException` is thrown naming the bad value and the valid values (`svg`, `png`); propagates to `Program.Main`'s expected-exception handler. @@ -85,8 +92,12 @@ future-locale story, which applies identically here. ##### Dependencies +- `StdlibProvider` (in `DemaConsulting.SysML2Tools.Stdlib`) — supplies the pre-resolved OMG + stdlib symbol table used to seed `WorkspaceLoader.LoadAsync` - `WorkspaceLoader` (in `DemaConsulting.SysML2Tools.Semantic`) — loads workspace -- `DiagramRenderer` (in `DemaConsulting.SysML2Tools.Rendering`) — renders views +- `DiagramRenderer` (in `DemaConsulting.SysML2Tools.Rendering`) — renders views; also exposes + `GetViewIdentities` used to attribute colliding output file names back to their originating + qualified view names - `SvgRenderer` (in `DemaConsulting.Rendering.Svg`) — produces SVG output - `PngRenderer` (in `DemaConsulting.Rendering.Skia`) — produces PNG output - `Themes.Light` (in `DemaConsulting.Rendering.Abstractions`) — default theme @@ -112,3 +123,4 @@ future-locale story, which applies identically here. | SysML2Tools-Tool-Render-UnknownViewError | Unknown `--view` name guard using `viewNames` in `RunAsync` | | SysML2Tools-Tool-Render-ViewSelection | `viewFilter` passed to `RenderWorkspace` in `RunAsync` | | SysML2Tools-Tool-Render-FormatValidation | Eager `--format` value guard in `RunAsync` | +| SysML2Tools-Tool-Render-FileNameCollision | Output file name collision guard in `RunAsync` | diff --git a/docs/reqstream/sysml2-tools-tool/render.yaml b/docs/reqstream/sysml2-tools-tool/render.yaml index 1fa50f3a..ec525936 100644 --- a/docs/reqstream/sysml2-tools-tool/render.yaml +++ b/docs/reqstream/sysml2-tools-tool/render.yaml @@ -116,6 +116,20 @@ sections: tests: - RenderSubsystem_UnsupportedFormat_ThrowsArgumentException + - id: SysML2Tools-Tool-Render-FileNameCollision + title: >- + The render command shall detect and report, before writing any output files, when + rendering all declared views (--view not specified) would produce two or more + output files with the same sanitized file name, naming the colliding views and the + shared file name, and shall abort without writing any files for that run. + justification: | + Two declared views in different packages that share the same simple name sanitize + to the same output file name. Without a collision guard, the second file written + silently overwrites the first while the final "Rendered N view(s)." message still + misreports both views as rendered, masking data loss from the user. + tests: + - RenderSubsystem_DuplicateViewFileNames_ReportsCollisionError + - id: SysML2Tools-Tool-Render-LocalizableHelpText title: >- The render command's help text (RenderCommand.PrintHelp) shall be sourced from diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 9976dbdf..61fa20b3 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -53,7 +53,7 @@ sysml2tools lint model.sysml sysml2tools lint "src/**/*.sysml" # Multiple patterns -sysml2tools render "common/**/*.sysml" "system/**/*.sysml" --output diagram.svg +sysml2tools render "common/**/*.sysml" "system/**/*.sysml" --output out ``` # Linting @@ -80,20 +80,21 @@ This structured output is suitable for: # Rendering The `render` command loads a workspace, resolves a view, and renders it to SVG or PNG. -The output format is determined by the file extension of `--output`. +`--output` names an output *directory* (default: current directory); `--format` selects +`svg` (default) or `png`. ```bash # Render to SVG -sysml2tools render model.sysml --output diagram.svg +sysml2tools render model.sysml --output out --format svg # Render to PNG -sysml2tools render model.sysml --output diagram.png +sysml2tools render model.sysml --output out --format png # Render a named view from a multi-view workspace -sysml2tools render "src/**/*.sysml" --view SystemContext --output context.svg +sysml2tools render "src/**/*.sysml" --view SystemContext --output out --format svg # Auto-render the top-level part def when no view is defined -sysml2tools render model.sysml --auto --output diagram.svg +sysml2tools render model.sysml --auto --output out --format svg ``` ## View Selection @@ -102,7 +103,7 @@ sysml2tools render model.sysml --auto --output diagram.svg | --- | --- | | Exactly one view in workspace | Render it | | Zero views, `--auto` specified | Auto-render BDD of top-level `part def` silently | -| Zero views, no `--auto` | Warn: "define a view or use --auto", then auto-render | +| Zero views, no `--auto` | Informational message; no output files written | | Multiple views, none specified | Render every declared view (one output file per view) | | Multiple views, `--view ` | Render only the named view | | `--view ` names a view that does not exist | Error: lists available view names, exits non-zero | @@ -114,7 +115,7 @@ with an ellipsis footer (`+N more…`). Silent omission is never used — trunca visible in the output. ```bash -sysml2tools render model.sysml --output diagram.svg --depth 3 +sysml2tools render model.sysml --output out --depth 3 ``` ## Output Formats diff --git a/docs/verification/sysml2-tools-tool/render.md b/docs/verification/sysml2-tools-tool/render.md index 346db1b9..b258097f 100644 --- a/docs/verification/sysml2-tools-tool/render.md +++ b/docs/verification/sysml2-tools-tool/render.md @@ -1,6 +1,6 @@ -### DemaConsulting.SysML2Tools.Tool — Render Subsystem Verification +## DemaConsulting.SysML2Tools.Tool — Render Subsystem Verification -#### Verification Approach +### Verification Approach The Render subsystem is verified using unit tests in `test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs`. @@ -8,16 +8,17 @@ Tests invoke `Program.RunAsync` with controlled `Context` instances and assert on context output and exit code. File-writing scenarios use a temporary directory (`Path.GetTempPath()`). Tests run against all three target frameworks. -#### Test Environment +### Test Environment - Framework: xUnit v3 - Target frameworks: net8.0, net9.0, net10.0 - Test project: `DemaConsulting.SysML2Tools.Tool.Tests` - Dependencies: `DemaConsulting.SysML2Tools.Tool` (internal access via `InternalsVisibleTo`) -#### Acceptance Criteria +### Acceptance Criteria -- No files supplied: `context.WriteError` is called and method returns without loading +- No files supplied: `context.WriteError` is called with a message containing "no input files + specified", and the method returns without loading - Workspace loads without errors for a valid SysML model file - SVG output produced for `--format svg` (or default) - PNG output produced for `--format png` @@ -27,6 +28,9 @@ on context output and exit code. File-writing scenarios use a temporary director - `--depth 1` produces SVG output containing the ellipsis character `"…"` - Multiple views without `--view` renders every declared view, producing one output file per view, exit code 0 +- Two views whose display names sanitize to the same output file name (declared in different + packages) yield exit code 1, an error message naming both colliding qualified views and the + shared file name, and no output files written - `--view ` with a multi-view workspace renders exactly one file - `--view ` naming a view that does not exist yields exit code 1 and an error message listing available view names @@ -36,73 +40,82 @@ on context output and exit code. File-writing scenarios use a temporary director list), and is identical to `help render`'s output (see the Help subsystem verification document). -#### Test Scenarios +### Test Scenarios -##### RenderSubsystem_NoFiles_ReportsError +#### RenderSubsystem_NoFiles_ReportsError -Verifies that invoking the render command with zero file patterns results in an -error message written to the context and no workspace loading. +Verifies that invoking the render command with zero file patterns results in an error message +written to the context containing the "no input files specified" diagnostic text, and no +workspace loading. -##### RenderSubsystem_WithFiles_LoadsWorkspace +#### RenderSubsystem_WithFiles_LoadsWorkspace Verifies that supplying a valid SysML model file loads without errors, producing a non-null workspace in the context. -##### RenderSubsystem_FormatSvg_UsesSvgRenderer +#### RenderSubsystem_FormatSvg_UsesSvgRenderer Verifies that `--format svg` routes to the SVG renderer by confirming output file extension is `.svg`. -##### RenderSubsystem_FormatPng_UsesPngRenderer +#### RenderSubsystem_FormatPng_UsesPngRenderer Verifies that `--format png` routes to the PNG renderer by confirming output file extension is `.png`. -##### RenderSubsystem_NoOutputDir_UsesCurrentDirectory +#### RenderSubsystem_NoOutputDir_UsesCurrentDirectory Verifies that omitting `--output` causes files to be written to the current working directory. -##### RenderSubsystem_NoViews_ReportsNoOutput +#### RenderSubsystem_NoViews_ReportsNoOutput Verifies that a model with no view declarations produces no output files and an informational message. -##### RenderSubsystem_WithDepth_LimitsNesting +#### RenderSubsystem_WithDepth_LimitsNesting Verifies that `--depth 1` causes the SVG output to contain the ellipsis character `"…"`, confirming that child part-def boxes were replaced by the depth-limit indicator. -##### RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews +#### RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews Verifies that rendering a workspace with two views and no `--view` flag renders every declared view: exit code 0, and exactly two `.svg` output files produced (one per view). -##### RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews +#### RenderSubsystem_DuplicateViewFileNames_ReportsCollisionError + +Verifies that rendering a workspace containing two views with the same simple name +(`SharedView`), declared in different packages (`PkgA`, `PkgB`), with no `--view` flag: exit +code 1, an error message naming both colliding qualified views (`PkgA::SharedView`, +`PkgB::SharedView`) and the shared output file name (`SharedView.svg`), and no output directory +or files are created. + +#### RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews Verifies that `--view ` yields exit code 1 and an error message listing the available view names (`ViewAlpha` and `ViewBeta`). -##### RenderSubsystem_MultipleViews_WithViewFlag_RendersSelectedView +#### RenderSubsystem_MultipleViews_WithViewFlag_RendersSelectedView Verifies that `--view ViewAlpha` selects exactly one view from a two-view workspace and produces a single `.svg` output file. -##### RenderSubsystem_UnsupportedFormat_ThrowsArgumentException +#### RenderSubsystem_UnsupportedFormat_ThrowsArgumentException Verifies that `--format xml` (an unsupported value) does not throw when `Context.Create` parses the arguments, but throws `ArgumentException` naming the bad value once `Program.RunAsync` actually runs the render command — mirroring the timing of the `query` command's `--format` validation. -##### RenderSubsystem_Help_PrintsRenderSpecificUsage +#### RenderSubsystem_Help_PrintsRenderSpecificUsage Verifies that `render --help` prints the render-specific usage line and its `--output`/ `--auto` flags, and does not print the generic top-level `"Commands:"` section — a regression-proofing test added alongside the `help` command's command-aware `--help` dispatch (see `docs/design/sysml2-tools-tool/help.md`). -##### ResxResource_EveryKey_ResolvesToNonEmptyText / ResxResource_KeysAndAccessorProperties_AreInBidirectionalParity (ResxResourceTests.cs) +#### ResxResource_EveryKey_ResolvesToNonEmptyText / ResxResource_KeysAndAccessorProperties_AreInBidirectionalParity (ResxResourceTests.cs) For the `RenderStrings` resource base name/accessor pair (one of four covered by these theory tests), every key discovered in `Render/RenderStrings.resx`'s invariant-culture resource set diff --git a/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs index db0e4ba0..d734f54f 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs @@ -37,6 +37,20 @@ public DiagramRenderer() { } + /// + /// Identifies a single renderable view by both its fully qualified name and its display + /// name (the name used to derive the output file name). + /// + /// + /// The view's fully qualified name (e.g. "PkgA::SharedView"), unique within the + /// workspace. + /// + /// + /// The view's display name: the simple Name when present, otherwise the qualified + /// name. Two views in different packages may share the same display name. + /// + public readonly record struct ViewIdentity(string QualifiedName, string DisplayName); + /// /// Returns the display names of all renderable user-defined views in the workspace, /// mirroring the filtering applied by . @@ -46,14 +60,34 @@ public DiagramRenderer() /// An ordered list of view display names. Returns an empty list when the workspace /// contains no renderable view declarations. /// - public static IReadOnlyList GetViewNames(SysmlWorkspace workspace) + public static IReadOnlyList GetViewNames(SysmlWorkspace workspace) => + GetViewIdentities(workspace).Select(identity => identity.DisplayName).ToList(); + + /// + /// Returns the qualified and display names of all renderable user-defined views in the + /// workspace, in the same order that produces its outputs. + /// + /// The SysML workspace whose view declarations are inspected. + /// + /// An ordered list of values, one per renderable view. Returns an + /// empty list when the workspace contains no renderable view declarations. + /// + /// + /// Callers that need to correlate each produced by + /// with the originating view's qualified name (for example, to + /// report a collision between two views that sanitize to the same output file name) may zip + /// this list by index against the result, because both methods + /// apply the identical filter/iteration over with no + /// intervening mutation. + /// + public static IReadOnlyList GetViewIdentities(SysmlWorkspace workspace) { // Validate input — null workspace would produce silent failures ArgumentNullException.ThrowIfNull(workspace); - var names = new List(); + var identities = new List(); - // Iterate over all declarations and collect each renderable view name + // Iterate over all declarations and collect each renderable view's identity foreach (var (qualifiedName, node) in workspace.Declarations) { // Skip non-view declarations @@ -79,10 +113,11 @@ public static IReadOnlyList GetViewNames(SysmlWorkspace workspace) _ = strategy; // Resolve the display name: prefer the simple name, fall back to qualified name - names.Add(viewNode.Name ?? qualifiedName); + var displayName = viewNode.Name ?? qualifiedName; + identities.Add(new ViewIdentity(qualifiedName, displayName)); } - return names; + return identities; } /// diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs index 6c0c536e..3df4f97b 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs @@ -122,6 +122,16 @@ public static async Task RunAsync(Context context) return; } + // When rendering every view (no --view filter) with more than one output, guard against + // two views whose display names sanitize to the same output file name — without this + // check, the second file written below would silently overwrite the first while the + // final "Rendered N view(s)." message still reports both as rendered. + if (options.ViewName is null && outputs.Count > 1 && + ReportFileNameCollisions(context, loadResult.Workspace, outputs)) + { + return; + } + // Determine the output directory (default: current directory) var outputDir = options.OutputDirectory ?? Directory.GetCurrentDirectory(); Directory.CreateDirectory(outputDir); @@ -144,6 +154,72 @@ public static async Task RunAsync(Context context) context.WriteLine($"Rendered {outputs.Count} view(s)."); } + /// + /// Detects and reports output file name collisions among the given render outputs, sourcing + /// each output's originating view qualified name from . + /// + /// The CLI context used to write the error message. + /// The workspace that produced . + /// + /// The render outputs to check, in the same order that + /// enumerates renderable views for this workspace. + /// + /// + /// when at least one collision was found and reported (the caller + /// must abort without writing any files); when no collision exists. + /// + /// + /// (from the external DemaConsulting.Rendering.Abstractions + /// package) exposes only SuggestedFileName, MediaType, Data, and + /// Warnings — it carries no reference back to the originating view — so the qualified + /// name for each output must be sourced separately, by index, from + /// . Both that method and + /// apply the identical filter/iteration over + /// with no mutation in between (the same + /// invariant this command already relies on when calling GetViewNames once for + /// --view validation and RenderWorkspace separately), so the two lists line up + /// by index. + /// + private static bool ReportFileNameCollisions( + Context context, + SysmlWorkspace workspace, + IReadOnlyList outputs) + { + var identities = DiagramRenderer.GetViewIdentities(workspace); + if (identities.Count != outputs.Count) + { + // Defensive: the two enumerations should always line up 1:1 (see remarks); if this + // invariant is ever broken by a future change, fail loudly rather than mis-attribute + // qualified names to the wrong output. + throw new InvalidOperationException( + $"render: internal error — view identity count ({identities.Count}) does not " + + $"match render output count ({outputs.Count})."); + } + + // Group the colliding qualified names by shared output file name + var groups = identities + .Select((identity, index) => (identity.QualifiedName, outputs[index].SuggestedFileName)) + .GroupBy(entry => entry.SuggestedFileName, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + return false; + } + + // Report every colliding group before aborting, so users see all collisions at once + foreach (var group in groups) + { + var qualifiedNames = string.Join(", ", group.Select(entry => entry.QualifiedName)); + context.WriteError( + $"render: output file name collision '{group.Key}' between views: {qualifiedNames}. " + + "Rename one of the views, or use --view to render a single view."); + } + + return true; + } + /// /// Prints help for the render command. /// diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs index ab3f0e2c..3672f1bd 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs @@ -57,7 +57,8 @@ public async Task RenderSubsystem_NoFiles_ReportsError() using var context = Context.Create(["render"]); await Program.RunAsync(context); - // Assert: error message written and exit code indicates failure + // Assert: expected diagnostic text written and exit code indicates failure + Assert.Contains("no input files specified", errWriter.ToString()); Assert.Equal(1, context.ExitCode); } finally @@ -366,6 +367,61 @@ public async Task RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews() } } + /// + /// RenderCommand reports a collision error and writes no output files when two views in + /// different packages share the same simple name (and therefore the same sanitized + /// output file name). + /// + [Fact] + public async Task RenderSubsystem_DuplicateViewFileNames_ReportsCollisionError() + { + // Arrange: write a SysML model with two packages each declaring a view of the same + // simple name ("SharedView"), producing qualified names "PkgA::SharedView" and + // "PkgB::SharedView" that both sanitize to the output file name "SharedView.svg" + const string sysmlWithDuplicateViewNames = """ + package PkgA { + part def BlockA {} + view def SharedView {} + } + package PkgB { + part def BlockB {} + view def SharedView {} + } + """; + + var tempDir = Path.Combine(Path.GetTempPath(), $"render_collision_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, "model.sysml"); + await File.WriteAllTextAsync( + tempFile, sysmlWithDuplicateViewNames, TestContext.Current.CancellationToken); + + var outputDir = Path.Combine(tempDir, "out"); + var originalError = Console.Error; + try + { + using var errWriter = new StringWriter(); + Console.SetError(errWriter); + + // Act: render without --view so both colliding views are considered for rendering + using var context = Context.Create(["render", "--output", outputDir, tempFile]); + await Program.RunAsync(context); + + // Assert: exit code indicates failure; error message names both colliding qualified + // views and the shared file name; no output directory/files were created + Assert.Equal(1, context.ExitCode); + var errorText = errWriter.ToString(); + Assert.Contains("PkgA::SharedView", errorText); + Assert.Contains("PkgB::SharedView", errorText); + Assert.Contains("SharedView.svg", errorText); + Assert.False(Directory.Exists(outputDir), "Expected no output directory to be created"); + } + finally + { + Console.SetError(originalError); + Directory.Delete(tempDir, recursive: true); + } + } + /// /// RenderCommand reports an error listing available view names when --view names a /// view that does not exist in the workspace.