diff --git a/.reviewmark.yaml b/.reviewmark.yaml index fbdd67e..1036c56 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -955,6 +955,22 @@ reviews: - "test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryOmgFixtureTests.cs" - "test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryErrorPathTests.cs" + - id: SysML2Tools-Tool-Export + title: Review of SysML2 Tools Export subsystem implementation + context: + - docs/design/sysml2-tools-tool.md + - docs/reqstream/sysml2-tools-tool.yaml + paths: + - "docs/reqstream/sysml2-tools-tool/export.yaml" + - "docs/design/sysml2-tools-tool/export.md" + - "docs/verification/sysml2-tools-tool/export.md" + - "src/DemaConsulting.SysML2Tools.Tool/Export/*.cs" + - "src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.resx" + - "test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs" + - "test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportTestFixtures.cs" + - "test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportSubsystemTests.cs" + - "test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs" + - id: SysML2Tools-Tool-Help title: Review of SysML2 Tools Help subsystem implementation context: diff --git a/README.md b/README.md index ef631d5..bbc237a 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,11 @@ documentation, CI/CD pipelines, and AI-assisted modeling workflows. `hierarchy`, `requirements`, `interface`, `connections`, `states`, `list`, `find`) for AI and human callers, with Markdown or JSON output — designed so an AI agent can query architecture and traceability facts directly instead of reading raw `.sysml` files +- **`export` Command**: Dumps the resolved semantic model (declarations, edges, + diagnostics) as a single JSON document or as JSON Lines (JSONL), for bulk/offline + AI-assisted analysis of an entire workspace at once - **`help` Command**: Print help for the tool itself, a specific command - (`lint`/`render`/`query`), or a specific `query` verb — identical output to the + (`lint`/`render`/`query`/`export`), or a specific `query` verb — identical output to the corresponding ` --help` - **GeneralView Layout**: Package-grouped definition block diagrams placed by a layered (ELK-style) engine with orthogonal specialization and membership edges, depth-coded fill colors, compartments, @@ -132,15 +135,37 @@ sysml2tools query uses --element Pkg::MyPart model.sysml --format json See [Querying](docs/user_guide/introduction.md#querying) in the user guide for the full verb reference and more examples. +### Exporting + +Dump the resolved semantic model as JSON or JSONL, for bulk/offline AI-assisted analysis: + +```bash +# Export the whole workspace as a single indented JSON document (default format) +sysml2tools export model.sysml + +# Export as JSON Lines (one compact JSON object per declaration/edge/diagnostic) +sysml2tools export model.sysml --format jsonl + +# Write to a file instead of stdout (--output names a FILE, not a directory) +sysml2tools export "src/**/*.sysml" --format jsonl --output model.jsonl + +# Include OMG standard library declarations/edges in the export +sysml2tools export model.sysml --include-stdlib +``` + +See [Exporting](docs/user_guide/introduction.md#exporting) in the user guide for the full +JSON/JSONL output shape. + ### Getting Help ```bash # Top-level help (same as bare --help) sysml2tools help -# Command-specific help (identical to `lint --help`/`render --help`) +# Command-specific help (identical to `lint --help`/`render --help`/`export --help`) sysml2tools help lint sysml2tools help render +sysml2tools help export # Query verb overview (identical to `query --help`) sysml2tools help query @@ -175,11 +200,11 @@ sysml2tools --silent --log output.log sysml2tools [-v|--version] [-?|-h|--help] [--silent] [--validate] [--result|--results ] [--depth <#>] [--log ] [ [verb-options] []] -sysml2tools help [lint|render|query []] +sysml2tools help [lint|render|query []|export] ``` -`` is `lint`, `render`, or `query ` (11 query verbs — see the *Querying* -section above). +`` is `lint`, `render`, `export`, or `query ` (11 query verbs — see the +*Querying* section above). ### Global Options @@ -230,11 +255,20 @@ section above). | `--name ` | Name substring filter (`list`/`find` verbs only) | | `--include-stdlib` | Include OMG standard library elements in results | +### `export` Options + +| Option | Description | +| --- | --- | +| `` | One or more glob patterns for `.sysml` input files | +| `--format json\|jsonl` | Output format (default: `json`) | +| `--output ` | Write export document to this **file** (default: stdout); differs from `render`'s `--output` dir | +| `--include-stdlib` | Include OMG standard library declarations/edges (diagnostics are never stdlib-filtered) | + ### `help` Options | Option | Description | | --- | --- | -| `[command]` | Optional: `lint`, `render`, or `query`; omit for top-level help | +| `[command]` | Optional: `lint`, `render`, `query`, or `export`; omit for top-level help | | `[verb]` | Optional; only meaningful when `command` is `query` — one of the 11 supported query verbs | ## View Selection @@ -265,7 +299,7 @@ see the *Introduction* user guide for details. | `DemaConsulting.SysML2Tools.Language` | Library: SysML v2/KerML parser, AST, semantic model | | `DemaConsulting.SysML2Tools.Stdlib` | Library: pre-compiled SysML v2 standard library | | `DemaConsulting.SysML2Tools.Core` | Library: parser, semantic model, layout, `IRenderer` interface | -| `DemaConsulting.SysML2Tools.Tool` | CLI tool: `lint`, `render`, and `query` commands | +| `DemaConsulting.SysML2Tools.Tool` | CLI tool: `lint`, `render`, `query`, and `export` commands | Library consumers can take a dependency on `DemaConsulting.SysML2Tools.Core` alone to access parsing, semantic model, and layout without pulling in the CLI tool; `Core` automatically pulls diff --git a/ROADMAP.md b/ROADMAP.md index 0a95d72..56ce2fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ The work falls into three themes: and finish the remaining view dynamics. - **Release & packaging** — self-validation coverage, package validation, and licensing/attribution. - **Model query & analysis** — further AI-analysis options beyond the completed dynamic - (ad-hoc) views feature. + (ad-hoc) views and `export` verb features. --- @@ -223,6 +223,14 @@ verification documentation for the full per-kind compatibility rules; the sequen compatibility-check gap is carried forward as a known limitation in the "View dynamics refinements" item above. +**Done:** `export` verb — `export [--format json|jsonl] [--output ] [--include-stdlib] +` dumps the resolved semantic model (declarations, edges, diagnostics) as a +single indented JSON document or as JSON Lines, for an agent harness to index locally and run +its own queries offline. Implemented by the new `Export` subsystem (`ExportCommand`, +`ExportResult`, `ExportResultSerializerContext`/`ExportLineSerializerContext`), reusing +`SysmlNode`/`SysmlEdge`/`SysmlDiagnostic` directly rather than a fourth parallel result shape. +See its design/requirements/verification documentation for the full JSON/JSONL output shape. + ### Additional AI-analysis options (candidates) Lower-priority options that further support AI analysis of a model; each is independently @@ -230,8 +238,6 @@ scoped and gated, and any may be pulled forward or dropped: - **`--format sarif` for `lint`** — `SysmlDiagnostic` is already structurally SARIF-compatible; emitting SARIF lets AI/CI toolchains consume lint findings through standard tooling. -- **`export` verb** — dump the resolved semantic model (symbol table + edges + diagnostics) as - JSON/JSONL for an agent harness to index locally and run its own queries offline. - **Metrics / summary query** — workspace-level counts and hotspots (most-depended-on elements, unverified requirements, orphan elements, cyclic specialization) to orient an AI before it starts, and to flag model-health issues in review. @@ -239,7 +245,7 @@ scoped and gated, and any may be pulled forward or dropped: removed / changed elements, edges, and requirement traces) so an AI reviewing a change sees the *model-level* impact of a PR, not just the text diff. - **Machine-readable `--format json` everywhere** — extend the shared formatter so every command - (not just `query`) can emit JSON, keeping the CLI uniformly scriptable. + (not just `query`/`export`) can emit JSON, keeping the CLI uniformly scriptable. --- diff --git a/docs/design/sysml2-tools-tool.md b/docs/design/sysml2-tools-tool.md index 14d0fdf..432c530 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 seven subsystems (`Cli`, `Lint`, `Render`, -`Help`, `Query`, `SelfTest`, `Utilities`): +system containing one top-level unit (`Program`) and eight subsystems (`Cli`, `Lint`, `Render`, +`Help`, `Query`, `Export`, `SelfTest`, `Utilities`): ```mermaid flowchart TD @@ -24,6 +24,9 @@ flowchart TD subgraph Query QueryCommand end + subgraph Export + ExportCommand + end subgraph SelfTest Validation end @@ -35,6 +38,7 @@ flowchart TD Program --> RenderCommand Program --> HelpCommand Program --> QueryCommand + Program --> ExportCommand Program --> Validation Validation --> Program Validation --> PathHelpers @@ -44,7 +48,8 @@ flowchart TD `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 +dispatches to `ExportCommand` when the `export` 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. @@ -58,8 +63,10 @@ self-testing, and uses `PathHelpers` to construct safe temporary file paths. `--results `, `--result ` (legacy alias for `--results`), `--depth `, and `--log `. Accepts `lint ` as a subcommand that invokes lint mode. Accepts `query [options] ` (11 verbs; `--element`/`-e`, `--format`, - `--direction`, `--kind`, `--name`, `--include-stdlib` options) as a preview subcommand whose - verbs currently report a "not yet implemented" diagnostic. Returns + `--direction`, `--kind`, `--name`, `--include-stdlib` options). Accepts + `export [options] ` (`--format json|jsonl`, `--output `, + `--include-stdlib` options) which dumps the resolved semantic model (declarations, edges, + diagnostics) as JSON or JSON Lines. Returns exit code 0 for success and 1 for failures. - *Constraints*: Unknown arguments cause exit code 1 and an error message on stderr. @@ -137,10 +144,12 @@ N/A - not a safety-classified software item. close any open log file handle. - Path safety: all caller-supplied path components are validated by `PathHelpers.SafePathCombine` before file-system use. -- Localization: `Program`, `Lint`, `Render`, and `Query`'s banner/help text (everything printed - by `PrintBanner`/`PrintTopLevelHelp`/`PrintHelp`/`PrintGeneralHelp`/`PrintVerbHelp`) is +- Localization: `Program`, `Lint`, `Render`, `Query`, and `Export`'s banner/help text + (everything printed by + `PrintBanner`/`PrintTopLevelHelp`/`PrintHelp`/`PrintGeneralHelp`/`PrintVerbHelp`) is sourced from per-subsystem `.resx` files via hand-written `ResourceManager` accessor classes - (`ProgramStrings`, `LintStrings`, `RenderStrings`, `QueryStrings`), so a future non-English + (`ProgramStrings`, `LintStrings`, `RenderStrings`, `QueryStrings`, `ExportStrings`), so a + future non-English locale can be added with zero code changes (see `docs/design/sysml2-tools-tool/program.md`). Operational/diagnostic messages (progress lines, error messages) remain plain string literals and are explicitly out of scope for diff --git a/docs/design/sysml2-tools-tool/export.md b/docs/design/sysml2-tools-tool/export.md new file mode 100644 index 0000000..65ea79c --- /dev/null +++ b/docs/design/sysml2-tools-tool/export.md @@ -0,0 +1,188 @@ +### DemaConsulting.SysML2Tools.Tool — Export Subsystem + +#### Overview + +The Export subsystem implements the `export` CLI command: a single-purpose command that +loads a workspace and dumps its resolved semantic model — every declaration, every semantic +edge, and every diagnostic — as JSON or JSON Lines (JSONL). Unlike `query`, which summarizes +a targeted analysis question into a small `QueryResult`, `export` is a lossless, faithful +dump of the whole model intended for offline/AI-assisted bulk analysis. It provides six +cooperating types: + +- `ExportOptions` — an immutable record capturing `Format`, `Output`, `IncludeStdlib`, and + `Files`, parsed by `Context.Create` for one `export` invocation. +- `ExportArgumentParser` — parses `--format`, `--output`, `--include-stdlib`, and positional + file globs, rejecting any other `-`-prefixed token, mirroring + `LintArgumentParser`/`RenderArgumentParser`'s shape. +- `ExportCommand` — the entry-point dispatcher, mirroring `LintCommand`/`RenderCommand`'s + `internal static class` shape with a `RunAsync(Context)` method and a `PrintHelp` method. + Loads the workspace, applies the `--include-stdlib` filter, builds an `ExportResult`, and + renders it as JSON or JSONL to `--output` or stdout. +- `ExportResult` — the envelope record (`Declarations`, `Edges`, `Diagnostics`) reusing the + existing `SysmlNode`/`SysmlEdge`/`SysmlDiagnostic` model types directly rather than + introducing a fourth parallel result shape. +- `ExportDeclarationLine` / `ExportEdgeLine` / `ExportDiagnosticLine` — small per-line wrapper + records used only by the JSONL rendering path, each carrying a `"Kind"` discriminator + (`"declaration"`/`"edge"`/`"diagnostic"`) plus the wrapped payload. +- `ExportResultSerializerContext` / `ExportLineSerializerContext` — two source-generated + `JsonSerializerContext` classes (see **Serialization Contexts** below). + +#### Command Semantics + +`ExportCommand.RunAsync` performs the same file-resolution and workspace-loading sequence as +`query`/`render` (`GlobFileCollector.Collect`, `StdlibProvider.GetSymbolTable()`, +`WorkspaceLoader.LoadAsync`), then: + +1. Filters `workspace.Declarations` down to entries whose qualified-name key is visible per + `IsVisible` (see **Stdlib Filtering** below), producing the `ExportResult.Declarations` + dictionary (still keyed by qualified name, so the JSON `Declarations` object round-trips + the workspace's own lookup key, not just an implicit array index). +2. Filters `workspace.Index.AllEdges` down to edges whose `Source` and `Target` are both + visible per `IsVisible`, producing `ExportResult.Edges`. +3. Copies `workspace.Diagnostics` verbatim into `ExportResult.Diagnostics` — diagnostics are + never stdlib-filtered (see **Stdlib Filtering** below). +4. Renders the resulting `ExportResult` as an indented JSON document (`--format json`, + default) or as JSONL (`--format jsonl`) — see **Output Model** below. +5. Writes the rendered text to the file named by `--output` (via `File.WriteAllText`) when + present, or to stdout via `context.WriteLine` otherwise. + +##### Stdlib Filtering + +`ExportCommand` defines its own `private static bool IsVisible(string qualifiedName, +SysmlWorkspace workspace, bool includeStdlib) => includeStdlib || +!workspace.StdlibNames.Contains(qualifiedName);` — a verbatim copy of the Tool project's +`Query.QueryEngine.IsVisible` logic. It cannot be shared directly because +`QueryEngine.IsVisible` is `private`, and because the Tool project cannot reference Core's +internal `StdlibFilter` type either; duplicating this one-line predicate locally is simpler +and lower-risk than exposing a new shared internal surface for a single boolean check. + +Diagnostics are deliberately **not** filtered by this predicate: `WorkspaceLoader` +diagnostics are only ever produced while parsing/resolving the user's own supplied files — +the stdlib symbol table returned by `StdlibProvider.GetSymbolTable()` is a pre-resolved seed +consumed as-is by `WorkspaceLoader`, not re-parsed, so it can never itself be a diagnostic +source. + +#### Output Model + +##### ExportResult Data Model + +- `Declarations` (`IReadOnlyDictionary`) — every visible declaration, + keyed by its qualified name. +- `Edges` (`IReadOnlyList`) — every visible semantic edge. +- `Diagnostics` (`IReadOnlyList`) — every diagnostic produced while loading + the workspace, unfiltered. + +##### JSON Rendering (`--format json`, default) + +A single indented JSON document is produced via +`JsonSerializer.Serialize(result, ExportResultSerializerContext.Default.ExportResult)`. The +`Declarations` property serializes as a qualified-name-keyed JSON object (not an array), +`Edges`/`Diagnostics` as JSON arrays. Each `SysmlNode` serializes using its own +pre-existing `[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]` / +`[JsonDerivedType]` attributes — no export-specific converter is required. + +##### JSONL Rendering (`--format jsonl`) + +`ExportCommand.RenderJsonLines` emits one compact (non-indented) JSON object per line: one +line per `Declarations` entry wrapped in `ExportDeclarationLine` (`Kind = "declaration"`), +one line per `Edges` entry wrapped in `ExportEdgeLine` (`Kind = "edge"`), one line per +`Diagnostics` entry wrapped in `ExportDiagnosticLine` (`Kind = "diagnostic"`) — declarations +first, then edges, then diagnostics, each serialized independently via +`JsonSerializer.Serialize(line, ExportLineSerializerContext.Default.ExportXxxLine)` and +joined with `\n`. JSONL's entire value proposition (line-oriented, grep/tail-friendly +streaming) requires one complete JSON value per line; a single envelope-per-line would defeat +that, hence three small dedicated wrapper types rather than one `ExportResult`-per-line. + +##### Serialization Contexts + +Source-generated `JsonSerializerContext` types apply their `WriteIndented` setting at the +context level only — there is no supported per-`Serialize`-call override in AOT-safe +source-generation mode. Since JSON output must be indented and JSONL output must be compact, +this rules out sharing one context between both paths. `ExportResultSerializerContext` +(`WriteIndented = true`, covering `ExportResult`/`SysmlNode`/`SysmlEdge`/`SysmlDiagnostic`) +and `ExportLineSerializerContext` (`WriteIndented = false`, covering the three line-wrapper +types) are therefore two separate, purpose-built contexts — the concrete resolution +recommended by this unit's planning report for the single-vs-dual-context design question, +implemented directly without re-spiking. + +#### ExportOptions + +- `Format` (`string?`) — `"json"` (default) or `"jsonl"` from `--format`. +- `Output` (`string?`) — a **file path** (not a directory) from `--output`; when omitted, + output goes to stdout. This differs in meaning from `render`'s `--output`, which names an + output *directory* for per-view files — the flag name is reused deliberately (avoiding a + differently-named flag for "where does output go") but the meaning is documented explicitly + in both the XML doc comment and `export --help` text to prevent confusion between the two + commands. +- `IncludeStdlib` (`bool`) — from `--include-stdlib`; mirrors `query`'s exact convention. +- `Files` (`IReadOnlyList`) — file glob patterns. + +#### ExportCommand + +##### Key Methods + +**`RunAsync(Context context)`** + +1. Throws `ArgumentException` if `context.Export` is `null` (defensive; unreachable when + `Program` dispatches correctly). +2. Throws `ArgumentException` naming the bad value when `Format` is neither `null`, `"json"`, + nor `"jsonl"` (case-insensitive). +3. `WriteError`s "no input files" and returns (exit code 1) when `options.Files` is empty. +4. Resolves `options.Files` via `GlobFileCollector.Collect`; `WriteError`s "no files matched + the given pattern(s)" and returns when the pattern list resolved to zero files. +5. Loads the workspace via `StdlibProvider.GetSymbolTable()` + `WorkspaceLoader.LoadAsync`; + `WriteError`s "workspace loading failed" and returns if `Workspace` is `null`. +6. Builds the filtered `ExportResult` per **Command Semantics** above. +7. Renders as JSON or JSONL per `options.Format`. +8. Writes to `options.Output` (file) or stdout. + +**`PrintHelp(Context context)`**: Prints the `export` usage line, its three options (with an +explicit note that `--output` names a file, not a directory), and one example invocation, +sourced from `ExportStrings`. + +#### Localization / Resource Strings + +`export`'s help text is sourced from `ExportStrings`, a hand-written, culture-aware +`ResourceManager` accessor over `Export/ExportStrings.resx`, following the exact pattern +established by `LintStrings`/`RenderStrings`/`QueryStrings` (see +`docs/design/sysml2-tools-tool/program.md`'s "Localization / Resource Strings" section for +the rationale). + +#### Error Handling + +- `context.Export is null`: `ArgumentException` (defensive; should not occur via `Program`). +- Unsupported `--format` value: `ArgumentException` naming the bad value. +- No input files: `context.WriteError`; `Context.ExitCode` becomes 1. +- Patterns given but none matched any files: `context.WriteError`; `Context.ExitCode` + becomes 1. +- Workspace failed to load: `context.WriteError`; `Context.ExitCode` becomes 1. + +#### Dependencies + +- `Context`, `SysmlCommand` (in `DemaConsulting.SysML2Tools.Cli`) — reads `Export` options; + writes output. +- `GlobFileCollector` (in `DemaConsulting.SysML2Tools.Io`) — resolves `options.Files` glob + patterns to concrete file paths before loading the workspace. +- `WorkspaceLoader`, `StdlibProvider`, `SysmlWorkspace`, `SemanticIndex`, `SysmlNode`, + `SysmlEdge`, `SysmlDiagnostic` (in `DemaConsulting.SysML2Tools.Semantic`/`.Internal`) — + workspace loading and the model dumped by `ExportCommand`. + +#### Callers + +- `Program.RunToolLogicAsync` — dispatches to `ExportCommand.RunAsync` when + `context.Command == SysmlCommand.Export`. +- `Program.RunAsync` — dispatches to `ExportCommand.PrintHelp` for the `export --help` case. + +#### Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| SysML2Tools-Tool-Export-Grammar | `ExportArgumentParser` | +| SysML2Tools-Tool-Export-Format | `ExportOptions.Format`; `ExportCommand.RunAsync`'s format validation | +| SysML2Tools-Tool-Export-Output | `ExportOptions.Output`; `ExportCommand.RunAsync`'s file-vs-stdout write | +| SysML2Tools-Tool-Export-StdlibFilter | `ExportCommand.IsVisible` | +| SysML2Tools-Tool-Export-NoFilesOrNoMatch | File-resolution guards in `ExportCommand.RunAsync` | +| SysML2Tools-Tool-Export-JsonEnvelope | `ExportResult`; `ExportResultSerializerContext` | +| SysML2Tools-Tool-Export-JsonlEnvelope | `Export*Line` records; `ExportLineSerializerContext` | +| SysML2Tools-Tool-Export-Help | `ExportCommand.PrintHelp`, called from `Program.RunAsync` | +| SysML2Tools-Tool-Export-SelfTest | `Validation.RunExportSelfTestAsync` | diff --git a/docs/reqstream/sysml2-tools-tool/export.yaml b/docs/reqstream/sysml2-tools-tool/export.yaml new file mode 100644 index 0000000..59c558a --- /dev/null +++ b/docs/reqstream/sysml2-tools-tool/export.yaml @@ -0,0 +1,144 @@ +--- +# Export Subsystem Requirements +# +# PURPOSE: +# - Define requirements for the DemaConsulting.SysML2Tools.Tool Export subsystem +# - ExportCommand implements the CLI export verb: loads a workspace and dumps its resolved +# semantic model (declarations, edges, diagnostics) as JSON or JSON Lines +# - ExportResult/ExportResultSerializerContext/ExportLineSerializerContext implement the +# envelope and the two serialization contexts (indented JSON vs. compact JSONL) +# - Requirements describe observable behavior of the export command's parsing, validation, +# stdlib filtering, and JSON/JSONL output shape + +sections: + - title: Export Subsystem Requirements + requirements: + - id: SysML2Tools-Tool-Export-Grammar + title: >- + The export command shall accept --format, --output, and --include-stdlib options, + plus one or more positional file glob patterns, and shall reject any other + `-`-prefixed token. + justification: | + A small, fixed flag vocabulary (mirroring the query/render commands' own per-command + argument parsers) keeps export's grammar simple and predictable, and rejecting + unrecognized flags prevents another command's flag from being silently accepted. + tests: + - ExportArgumentParser_NoFlags_ProducesDefaults + - ExportArgumentParser_FormatFlag_CapturesRawValue + - ExportArgumentParser_OutputFlag_CapturesValue + - ExportArgumentParser_IncludeStdlibFlag_SetsTrue + - ExportArgumentParser_UnrecognizedFlag_ThrowsArgumentException + - ExportArgumentParser_FormatFlagMissingValue_ThrowsArgumentException + - ExportArgumentParser_OutputFlagMissingValue_ThrowsArgumentException + + - id: SysML2Tools-Tool-Export-Format + title: >- + The export command shall accept --format json or --format jsonl, defaulting to + json, and shall report a clear error naming the invalid value for anything else. + justification: | + json (a single indented document) is the natural default for typical CLI-invoked + model sizes; jsonl (one compact record per line) is an explicit opt-in for + streaming/line-oriented consumption by an agent harness over larger workspaces. + tests: + - ExportSubsystem_FormatJson_DispatchesAndPrintsJson + - ExportSubsystem_NoFormat_DefaultsToJson + - ExportSubsystem_FormatJsonl_DispatchesAndPrintsJsonLines + - ExportSubsystem_InvalidFormat_ThrowsArgumentException + + - id: SysML2Tools-Tool-Export-Output + title: >- + The export command shall write its rendered document to the file path supplied via + --output when present, or to stdout otherwise; unlike the render command's + --output (an output directory), export's --output names a single output file. + justification: | + Export produces one document (not one file per view like render), so a single + output file path is the natural shape; reusing the --output flag name (with a + documented, different meaning) avoids introducing a second, differently-named flag + for "where does the primary output go". + tests: + - ExportSubsystem_OutputFlag_WritesToFileInsteadOfStdout + + - id: SysML2Tools-Tool-Export-StdlibFilter + title: >- + The export command shall exclude OMG standard-library declarations and edges from + its output by default, and include them when --include-stdlib is supplied; + diagnostics shall never be stdlib-filtered. + justification: | + Mirrors the query command's exact --include-stdlib convention so the two + model-analysis commands behave consistently; diagnostics are never stdlib-filtered + because WorkspaceLoader diagnostics only ever originate from the user's own files + (the stdlib symbol table is a pre-resolved seed, not re-parsed per invocation). + tests: + - ExportSubsystem_NoIncludeStdlib_ExcludesStdlib + - ExportSubsystem_IncludeStdlib_IncludesStdlibAndIncreasesSize + - ExportIntegration_RealFixture_ProducesValidJsonAndJsonl + + - id: SysML2Tools-Tool-Export-NoFilesOrNoMatch + title: >- + The export command shall report a clear "no input files" error when no file + patterns are supplied, and a clear "no files matched" error when one or more + patterns are supplied but none resolve to a file on disk, in both cases exiting + with a non-zero status. + justification: | + Mirrors the lint/render/query commands' existing file-resolution error handling so + all four commands give consistent, actionable feedback for the same class of + mistake. + tests: + - ExportSubsystem_NoFiles_ReportsNoInputFilesError + - ExportSubsystem_NoMatchingFiles_ReportsNoFilesMatchedError + + - id: SysML2Tools-Tool-Export-JsonEnvelope + title: >- + The export command's --format json output shall be a single indented JSON document + containing a qualified-name-keyed "Declarations" object, an "Edges" array, and a + "Diagnostics" array, with declaration nodes serialized using their existing + polymorphic $type discriminator. + justification: | + Reusing SysmlNode/SysmlEdge/SysmlDiagnostic directly (rather than a fourth parallel + shape) keeps export's output a faithful, lossless, round-trip-capable dump of the + resolved semantic model, distinct from query's narrow QueryResult summarization + shape. + tests: + - ExportResultSerializerContext_DefinitionNode_RoundTripsTypeDiscriminator + - ExportResultSerializerContext_FeatureNode_UsesFeatureTypeDiscriminator + - ExportResultSerializerContext_EdgeKind_RoundTripsExactly + - ExportResultSerializerContext_Diagnostic_AllFieldsPresent + - ExportIntegration_RealFixture_ProducesValidJsonAndJsonl + + - id: SysML2Tools-Tool-Export-JsonlEnvelope + title: >- + The export command's --format jsonl output shall be one compact (non-indented) + JSON object per declaration/edge/diagnostic, each tagged with a "kind" + discriminator ("declaration"/"edge"/"diagnostic"), one record per line. + justification: | + JSONL's entire value proposition is per-record streaming/line-oriented consumption + (grep/tail-friendly); wrapping the whole envelope in a single line would defeat that, + hence dedicated per-line wrapper record types rather than one-envelope-per-line. + tests: + - ExportLineSerializerContext_DeclarationLine_HasKindDiscriminatorAndIsCompact + - ExportLineSerializerContext_EdgeLine_HasKindDiscriminatorAndIsCompact + - ExportLineSerializerContext_DiagnosticLine_HasKindDiscriminatorAndIsCompact + - ExportIntegration_RealFixture_ProducesValidJsonAndJsonl + + - id: SysML2Tools-Tool-Export-Help + title: >- + The export command shall render help text (via `export --help` or `help export`) + describing its flags, including an explicit note that --output names a file (not + a directory, unlike render's --output of the same name). + justification: | + Given the --output flag's collision-in-meaning with render's --output, explicit + help text is required to prevent user confusion between the two commands. + tests: + - ExportSubsystem_ExportHelp_PrintsHelpWithoutThrowing + + - id: SysML2Tools-Tool-Export-SelfTest + title: >- + The self-validation suite (--validate) shall include an export self-test that loads + the built-in self-test model, builds an export result, serializes it as JSON, and + asserts the expected declarations are present. + justification: | + Mirrors the established per-capability self-test convention (SysML2Tools_LintSelfTest, + SysML2Tools_RenderSvgSelfTest, etc.), giving export the same qualification evidence + as every other command. + tests: + - Validation_RunExportSelfTest_ValidModel_Passes diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index aa03233..1a6584c 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -414,6 +414,79 @@ the same order, so either format can be relied on for automated comparisons. | `list` | no | Enumerate elements, optionally filtered by `--kind`/`--name` | | `find` | no | Search elements — requires `--kind` and/or `--name` | +# Exporting + +The `export` command loads a workspace, resolves the semantic model, and dumps the *entire* +model — every declaration, every semantic edge, and every diagnostic — as a single JSON +document or as JSON Lines (JSONL). Unlike `query`, which answers a targeted analysis +question about one element, `export` is a lossless, bulk dump intended for offline/AI- +assisted analysis of a whole workspace at once (e.g., loading it into a separate tool, +`jq`-based scripting, or feeding an entire workspace's facts to an LLM in one shot). + +```bash +# Export the whole workspace as a single indented JSON document (default format) +sysml2tools export "src/**/*.sysml" + +# Export as JSON Lines (one compact JSON object per declaration/edge/diagnostic) +sysml2tools export "src/**/*.sysml" --format jsonl + +# Write to a file instead of stdout +sysml2tools export "src/**/*.sysml" --format jsonl --output model.jsonl + +# Include OMG standard library declarations/edges in the export +sysml2tools export "src/**/*.sysml" --include-stdlib +``` + +## `export` Options + +| Option | Description | +| --- | --- | +| `` | One or more glob patterns for `.sysml` input files | +| `--format json\|jsonl` | Output format (default: `json`) | +| `--output ` | Write to this **file** (default: stdout); `render`'s `--output` is a *directory* instead | +| `--include-stdlib` | Include OMG stdlib decls/edges (excluded by default); diagnostics are never stdlib-filtered | + +## Export Output Shape + +**`--format json`** (default) is a single indented document: + +```json +{ + "Declarations": { + "Model::Vehicle": { "$type": "definition", "Kind": "part def", "...": "..." }, + "Model::Engine": { "$type": "definition", "Kind": "part def", "...": "..." } + }, + "Edges": [ + { "SourceQualifiedName": "Model::Engine", "TargetQualifiedName": "Model::Vehicle", "Kind": "Composition" } + ], + "Diagnostics": [] +} +``` + +- `Declarations` is a JSON object keyed by qualified name (not an array), so a caller can + look up a specific element directly instead of scanning an array. +- Each declaration is serialized using its own existing polymorphic `$type` discriminator + (the same node types used internally by the parser/semantic model), so the export is a + faithful, round-trip-capable dump — not a separate, narrower summary shape like `query`'s + result. +- `Edges` and `Diagnostics` are plain JSON arrays. + +**`--format jsonl`** emits one compact JSON object per line, each tagged with a `"Kind"` +discriminator so a line-oriented consumer (`grep`, `jq -c`, streaming parsers) can process +records without buffering the whole document: + +```jsonl +{"Kind":"declaration","QualifiedName":"Model::Vehicle","Node":{"$type":"definition","...":"..."}} +{"Kind":"edge","SourceQualifiedName":"Model::Engine","TargetQualifiedName":"Model::Vehicle","EdgeKind":"Composition"} +{"Kind":"diagnostic","FilePath":"model.sysml","Line":1,"Column":1,"Severity":"Error","Message":"..."} +``` + +Declarations are emitted first, then edges, then diagnostics. Both output shapes exclude +OMG standard-library declarations and edges by default (mirroring `query`'s +`--include-stdlib` convention exactly); diagnostics are always included, since +`WorkspaceLoader` diagnostics only ever come from the user's own supplied files (the stdlib +symbol table is a pre-resolved seed, never re-parsed). + # Global Options The following global options are accepted before the verb: @@ -440,9 +513,10 @@ the other. # Top-level help (same as bare --help) sysml2tools help -# Command-specific help (identical to `lint --help`/`render --help`) +# Command-specific help (identical to `lint --help`/`render --help`/`export --help`) sysml2tools help lint sysml2tools help render +sysml2tools help export # Query verb overview (identical to `query --help`) sysml2tools help query diff --git a/docs/verification/sysml2-tools-tool.md b/docs/verification/sysml2-tools-tool.md index 9b9d873..4b54753 100644 --- a/docs/verification/sysml2-tools-tool.md +++ b/docs/verification/sysml2-tools-tool.md @@ -12,6 +12,12 @@ outputs; no internal implementation details are assumed. (e.g., "standard error is empty") are not possible at this level; all assertions are made against the combined output. +Detailed, per-subsystem verification for each of the tool's eight subsystems (`Cli`, `Lint`, +`Render`, `Help`, `Query`, `Export`, `SelfTest`, `Utilities`) is tracked separately under +`docs/verification/sysml2-tools-tool/*.md` (`cli.md`, `lint.md`, `render.md`, `help.md`, +`query.md`, `export.md`, `self-test.md`, `utilities.md`), matching the subsystem list in +`docs/design/sysml2-tools-tool.md`. + ## Test Environment Integration tests run on .NET 8.0, .NET 9.0, and .NET 10.0 across Windows, Linux, and macOS. @@ -25,6 +31,9 @@ combinations. Temporary files and directories are created and cleaned up within - Exit code non-zero is returned for all invalid argument combinations. - Results files are created at the specified paths when `--results` is used with `--validate`. - Silent mode (`--silent`) produces empty combined output. +- The `export` subcommand produces valid JSON/JSONL output for a real fixture, honors + `--include-stdlib`, and is covered by dedicated Export-subsystem tests (see + `docs/verification/sysml2-tools-tool/export.md`). ## Test Scenarios diff --git a/docs/verification/sysml2-tools-tool/export.md b/docs/verification/sysml2-tools-tool/export.md new file mode 100644 index 0000000..b235390 --- /dev/null +++ b/docs/verification/sysml2-tools-tool/export.md @@ -0,0 +1,153 @@ +### DemaConsulting.SysML2Tools.Tool — Export Subsystem Verification + +#### Verification Approach + +The Export subsystem is verified using unit/integration tests in two files under +`test/DemaConsulting.SysML2Tools.Tool.Tests/Export/` (`ExportSubsystemTests.cs`, +`ExportRenderingTests.cs`), plus export-specific parsing tests in +`test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs` and a self-test coverage +test in `test/DemaConsulting.SysML2Tools.Tool.Tests/SelfTest/ValidationTests.cs`. Tests +invoke `Context.Create`, `ExportCommand.RunAsync`, `Program.RunAsync`, and (for the +end-to-end scenario) the built tool as a real process via `Runner.Run`, asserting on +captured console output, exit code, produced file content, and deserialized JSON/JSONL +shape. Tests run against all three target frameworks. + +#### 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`); + OMG reference models under `test/SysMLModels/OMG/` (locatable via a repo-root search + upward from the test assembly's output directory). + +#### Acceptance Criteria + +- `--format json` (and the default, no `--format`) and `--format jsonl` both parse and + dispatch without error; an unrecognized `--format` value produces an `ArgumentException`. +- `--output ` writes the rendered document to the named file instead of stdout. +- `--include-stdlib` toggles whether stdlib-seeded declarations/edges appear in the export; + omitting it excludes them by default, matching `query`'s convention. +- Diagnostics are always present in the export output regardless of `--include-stdlib`. +- Unrecognized `-`-prefixed flags are rejected with an `ArgumentException`. +- No input files produces the "no input files" error and exit code 1; one or more patterns + matching zero files produces the "no files matched" error and exit code 1. +- JSON output is a single indented document with a qualified-name-keyed `Declarations` + object, an `Edges` array, and a `Diagnostics` array; declaration nodes carry their + polymorphic `$type` discriminator and round-trip through `SysmlNode`'s own converter. +- JSONL output is one compact JSON object per line, each carrying a `"Kind"` discriminator + (`"declaration"`/`"edge"`/`"diagnostic"`), with declarations, then edges, then diagnostics. +- A full CLI invocation (`export --format json`/`export --format jsonl --output `) + against a real `test/SysMLModels` fixture produces valid, deserializable JSON/JSONL + containing non-empty declarations, edges, and diagnostics, with stdlib content correctly + included/excluded per `--include-stdlib`. +- `export --help` prints usage/help text without throwing, including an explicit note that + `--output` names a file (not a directory, unlike `render`'s `--output`). +- The `SysML2Tools_ExportSelfTest` self-test (part of `--validate`) passes. +- Existing `lint`/`render`/`query` test suites continue to pass unmodified, confirming no + regression. + +#### Test Scenarios + +##### ExportSubsystemTests.cs + +**`ExportArgumentParser_NoFlags_ProducesDefaults`**: Verifies that parsing with only file +globs produces `Format = null`, `Output = null`, `IncludeStdlib = false`. + +**`ExportArgumentParser_FormatFlag_CapturesRawValue`**: Verifies `--format jsonl` populates +`Format`. + +**`ExportArgumentParser_OutputFlag_CapturesValue`**: Verifies `--output out.json` populates +`Output`. + +**`ExportArgumentParser_IncludeStdlibFlag_SetsTrue`**: Verifies `--include-stdlib` sets +`IncludeStdlib` to `true`. + +**`ExportArgumentParser_UnrecognizedFlag_ThrowsArgumentException`**: Verifies an unknown +`-`-prefixed token throws `ArgumentException`. + +**`ExportArgumentParser_FormatFlagMissingValue_ThrowsArgumentException`** / +**`ExportArgumentParser_OutputFlagMissingValue_ThrowsArgumentException`**: Verifies a +trailing `--format`/`--output` with no following value throws `ArgumentException`. + +**`ExportSubsystem_FormatJson_DispatchesAndPrintsJson`** / +**`ExportSubsystem_NoFormat_DefaultsToJson`** / +**`ExportSubsystem_FormatJsonl_DispatchesAndPrintsJsonLines`**: Verifies each accepted +`--format` value (and the default) dispatches successfully and produces output in the +expected shape. + +**`ExportSubsystem_InvalidFormat_ThrowsArgumentException`**: Verifies an unsupported +`--format` value throws `ArgumentException` naming the bad value. + +**`ExportSubsystem_OutputFlag_WritesToFileInsteadOfStdout`**: Verifies `--output ` +writes the rendered document to the named file and that stdout does not contain the JSON +body. + +**`ExportSubsystem_NoIncludeStdlib_ExcludesStdlib`** / +**`ExportSubsystem_IncludeStdlib_IncludesStdlibAndIncreasesSize`**: Verifies stdlib +declarations/edges are excluded by default and included (with measurably larger output) when +`--include-stdlib` is supplied. + +**`ExportSubsystem_NoFiles_ReportsNoInputFilesError`** / +**`ExportSubsystem_NoMatchingFiles_ReportsNoFilesMatchedError`**: Verifies the two +file-resolution error paths report the expected message and exit code 1. + +**`ExportSubsystem_ExportHelp_PrintsHelpWithoutThrowing`**: Verifies `export --help` prints +help text (including the `--output` file-vs-directory clarification) and exits with code 0. + +##### ExportRenderingTests.cs + +**`ExportResultSerializerContext_DefinitionNode_RoundTripsTypeDiscriminator`** / +**`ExportResultSerializerContext_FeatureNode_UsesFeatureTypeDiscriminator`**: Verifies JSON +output for definition/feature declarations carries the correct `$type` discriminator and +deserializes back to an equivalent node. + +**`ExportResultSerializerContext_EdgeKind_RoundTripsExactly`**: Verifies each +`SysmlEdgeKind` value serializes/deserializes to the same enum value in JSON output. + +**`ExportResultSerializerContext_Diagnostic_AllFieldsPresent`**: Verifies diagnostic entries +in JSON output carry their expected fields (message, severity, location). + +**`ExportLineSerializerContext_DeclarationLine_HasKindDiscriminatorAndIsCompact`** / +**`ExportLineSerializerContext_EdgeLine_HasKindDiscriminatorAndIsCompact`** / +**`ExportLineSerializerContext_DiagnosticLine_HasKindDiscriminatorAndIsCompact`**: Verifies +each JSONL line-wrapper type serializes with the correct `"Kind"` value and is a single +compact (non-indented, single-line) JSON object. + +**`ExportIntegration_RealFixture_ProducesValidJsonAndJsonl`**: A full CLI end-to-end test +(via `Runner.Run`, mirroring `IntegrationTests.cs`'s process-invocation style) against +`test/SysMLModels/OMG/examples/VehicleExample/VehicleDefinitions.sysml`: runs +`export --format json` and asserts the captured output contains a valid, deserializable JSON +document with non-empty `Declarations`/`Edges`/`Diagnostics`; runs +`export --format jsonl --output ` and asserts the file contains one JSON object per +line, each parseable and carrying the expected `"Kind"` discriminator. Satisfies +`SysML2Tools-Tool-Export-StdlibFilter`, `SysML2Tools-Tool-Export-JsonEnvelope`, and +`SysML2Tools-Tool-Export-JsonlEnvelope`. + +##### Validation_RunExportSelfTest_ValidModel_Passes (ValidationTests.cs) + +Verifies that `Validation.RunExportSelfTestAsync` passes against the built-in self-test +model, reporting a passing `TestResult` named `SysML2Tools_ExportSelfTest`. Satisfies +`SysML2Tools-Tool-Export-SelfTest`. + +##### ContextTests.cs + +**`Context_Create_ExportCommand_SetsCommandExport`**: Verifies that +`Context.Create(["export"])` sets `Command` to `SysmlCommand.Export` with exit code 0. + +**`Context_Create_ExportCommand_WithFormat_SetsFormat`**: Verifies that `--format jsonl` +populates `Export.Format`. + +**`Context_Create_ExportCommand_WithOutput_SetsOutput`**: Verifies that +`--output out.json` populates `Export.Output`. + +**`Context_Create_ExportCommand_WithIncludeStdlibFlag_SetsIncludeStdlibTrue`**: Verifies +that `--include-stdlib` sets `Export.IncludeStdlib` to `true`. + +**`Context_Create_ExportCommand_WithFiles_SetsFiles`**: Verifies that a file pattern +supplied after the `export` token populates `Export.Files` with the matching glob. + +**`Context_Create_ExportCommand_FormatWithoutValue_ThrowsArgumentException`** / +**`Context_Create_ExportCommand_OutputWithoutValue_ThrowsArgumentException`**: Verifies +that a trailing `--format`/`--output` with no following value throws `ArgumentException` +naming the flag, mirroring the `render`/`query` commands' equivalent dispatch-level tests. diff --git a/requirements.yaml b/requirements.yaml index 0cfcff2..9e8d102 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -47,6 +47,7 @@ includes: - docs/reqstream/sysml2-tools-tool/lint.yaml - docs/reqstream/sysml2-tools-tool/render.yaml - docs/reqstream/sysml2-tools-tool/query.yaml + - docs/reqstream/sysml2-tools-tool/export.yaml - docs/reqstream/sysml2-tools-tool/help.yaml - docs/reqstream/sysml2-tools-tool/self-test.yaml - docs/reqstream/sysml2-tools-tool/self-test/validation.yaml diff --git a/src/DemaConsulting.SysML2Tools.Tool/Cli/Context.cs b/src/DemaConsulting.SysML2Tools.Tool/Cli/Context.cs index 867727b..8c66739 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Cli/Context.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Cli/Context.cs @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using DemaConsulting.SysML2Tools.Export; using DemaConsulting.SysML2Tools.Help; using DemaConsulting.SysML2Tools.Lint; using DemaConsulting.SysML2Tools.Query; @@ -109,6 +110,12 @@ internal sealed class Context : IDisposable /// public QueryOptions? Query { get; private init; } + /// + /// Gets the parsed options for the export command; unless + /// is . + /// + public ExportOptions? Export { get; private init; } + /// /// Gets the parsed options for the help command; unless /// is . @@ -152,6 +159,7 @@ public static Context Create(string[] args) LintOptions? lintOptions = null; RenderCommandOptions? renderOptions = null; QueryOptions? queryOptions = null; + ExportOptions? exportOptions = null; HelpOptions? helpOptions = null; switch (global.Command) @@ -168,6 +176,10 @@ public static Context Create(string[] args) queryOptions = QueryArgumentParser.Parse(global.CommandArgs, global.Help, global.MaxRenderDepth); break; + case SysmlCommand.Export: + exportOptions = ExportArgumentParser.Parse(global.CommandArgs); + break; + case SysmlCommand.Help: helpOptions = HelpArgumentParser.Parse(global.CommandArgs); break; @@ -198,6 +210,7 @@ public static Context Create(string[] args) Lint = lintOptions, Render = renderOptions, Query = queryOptions, + Export = exportOptions, HelpCommand = helpOptions }; diff --git a/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArgumentParser.cs b/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArgumentParser.cs index e49df16..9ed142c 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArgumentParser.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArgumentParser.cs @@ -107,6 +107,10 @@ public static GlobalArguments Parse(string[] args) command = SysmlCommand.Query; break; + case "export" when command == SysmlCommand.None: + command = SysmlCommand.Export; + break; + case "help" when command == SysmlCommand.None: command = SysmlCommand.Help; break; diff --git a/src/DemaConsulting.SysML2Tools.Tool/Cli/SysmlCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Cli/SysmlCommand.cs index 245926c..3f698d4 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Cli/SysmlCommand.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Cli/SysmlCommand.cs @@ -37,6 +37,9 @@ internal enum SysmlCommand /// Run a model-analysis query verb. Query, + /// Export the resolved semantic model as JSON or JSON Lines. + Export, + /// Print help for the tool or a specific command/verb. Help } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportArgumentParser.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportArgumentParser.cs new file mode 100644 index 0000000..618582c --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportArgumentParser.cs @@ -0,0 +1,98 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using DemaConsulting.SysML2Tools.Cli; + +namespace DemaConsulting.SysML2Tools.Export; + +/// +/// Parses the arguments remaining after the export command token into an +/// instance. +/// +/// +/// Recognizes only --format, --output, and --include-stdlib, plus +/// positional file glob patterns. Any other --prefixed token is rejected so that flags +/// belonging to other commands (e.g., --view, --kind) are never silently +/// accepted. --format's value is captured raw and validated later by +/// , matching the query/render commands' +/// validation style. +/// +internal static class ExportArgumentParser +{ + /// + /// Parses the export command's arguments. + /// + /// + /// The arguments remaining after the global parser has stripped cross-cutting flags and + /// the export command token. + /// + /// The parsed . + /// + /// Thrown when an unrecognized flag is supplied, or a recognized flag requiring a value is + /// missing one. + /// + public static ExportOptions Parse(IReadOnlyList commandArgs) + { + string? format = null; + string? output = null; + var includeStdlib = false; + var files = new List(); + + var index = 0; + while (index < commandArgs.Count) + { + var arg = commandArgs[index++]; + switch (arg) + { + case "--format": + format = CliArgumentHelpers.GetRequiredStringArgument( + arg, commandArgs, ref index, "a format argument (json or jsonl)"); + break; + + case "--output": + output = CliArgumentHelpers.GetRequiredStringArgument( + arg, commandArgs, ref index, "an output file path argument"); + break; + + case "--include-stdlib": + includeStdlib = true; + break; + + default: + if (arg.StartsWith("-", StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Unsupported argument '{arg}' for the 'export' command.", nameof(commandArgs)); + } + + files.Add(arg); + break; + } + } + + return new ExportOptions + { + Format = format, + Output = output, + IncludeStdlib = includeStdlib, + Files = files + }; + } +} diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportCommand.cs new file mode 100644 index 0000000..ee22bf4 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportCommand.cs @@ -0,0 +1,236 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Text; +using System.Text.Json; +using DemaConsulting.SysML2Tools.Cli; +using DemaConsulting.SysML2Tools.Io; +using DemaConsulting.SysML2Tools.Parser; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Model; +using DemaConsulting.SysML2Tools.Stdlib; + +namespace DemaConsulting.SysML2Tools.Export; + +/// +/// Implements the export command: loads a SysML v2 workspace and dumps its resolved +/// semantic model (declarations, edges, diagnostics) as a single JSON document or as JSON +/// Lines, for offline consumption by an AI/agent harness. +/// +/// +/// Stdlib filtering mirrors Query.QueryEngine.IsVisible exactly (replicated locally, +/// since this Tool project cannot reference the Core project's internal +/// StdlibFilter): excludes stdlib keys, and +/// excludes any edge whose source or target is a stdlib +/// name, unless --include-stdlib was supplied. Diagnostics are never stdlib-filtered: +/// WorkspaceLoader diagnostics only ever originate from the user's own files, because +/// the stdlib symbol table is a pre-resolved seed (StdlibProvider.GetSymbolTable()), +/// not re-parsed per invocation — see docs/design/sysml2-tools-tool/export.md. +/// +internal static class ExportCommand +{ + /// + /// Runs the export command. + /// + /// The CLI context, supplying the parsed and output methods. + /// + /// Thrown when is , or when + /// --format is not json/jsonl. + /// + public static async Task RunAsync(Context context) + { + var options = context.Export + ?? throw new ArgumentException("export: no export options were parsed.", nameof(context)); + + // Reject unsupported --format values up front, before doing any work, mirroring the + // query/render commands' --format validation style. + var format = options.Format ?? "json"; + if (!format.Equals("json", StringComparison.OrdinalIgnoreCase) && + !format.Equals("jsonl", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"export: unsupported --format value '{format}'. Valid values are: json, jsonl.", + nameof(context)); + } + + // Validate that at least one file pattern was supplied + if (options.Files.Count == 0) + { + context.WriteError("export: no input files specified. Provide one or more .sysml or .kerml file paths."); + return; + } + + // Resolve the supplied file glob patterns to concrete file paths via the shared + // GlobFileCollector, supporting recursive '**' patterns and '!' exclusions. + context.WriteLine($"Loading {options.Files.Count} file pattern(s)..."); + var files = GlobFileCollector.Collect(options.Files, [".sysml", ".kerml"], Directory.GetCurrentDirectory()); + if (files.Count == 0) + { + context.WriteError("export: no files matched the given pattern(s)."); + return; + } + + context.WriteLine($"Resolved {files.Count} file(s) from {options.Files.Count} pattern(s)."); + + // Load the workspace from the resolved file paths, exactly as 'lint'/'render'/'query' do + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var loadResult = await WorkspaceLoader.LoadAsync(files, stdlibTable).ConfigureAwait(false); + + if (loadResult.Workspace is null) + { + context.WriteError("export: workspace loading failed; no export produced."); + return; + } + + var workspace = loadResult.Workspace; + + // Build the filtered declarations/edges, applying the --include-stdlib convention + var declarations = workspace.Declarations + .Where(kv => IsVisible(kv.Key, workspace, options.IncludeStdlib)) + .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal); + + var edges = workspace.Index.AllEdges + .Where(edge => + (edge.SourceQualifiedName is null || IsVisible(edge.SourceQualifiedName, workspace, options.IncludeStdlib)) && + IsVisible(edge.TargetQualifiedName, workspace, options.IncludeStdlib)) + .ToList(); + + // Diagnostics are never stdlib-filtered — see this class's remarks. + var result = new ExportResult + { + Declarations = declarations, + Edges = edges, + Diagnostics = loadResult.Diagnostics + }; + + var rendered = format.Equals("jsonl", StringComparison.OrdinalIgnoreCase) + ? RenderJsonLines(result) + : JsonSerializer.Serialize(result, ExportResultSerializerContext.Default.ExportResult); + + // Write to --output file if specified, else stdout via context.WriteLine + if (options.Output is not null) + { + try + { + // Ensure the parent directory exists (mirroring 'render's Directory.CreateDirectory + // guard for its --output directory), so a nonexistent output path fails cleanly + // rather than throwing DirectoryNotFoundException from WriteAllTextAsync below. + var outputDir = Path.GetDirectoryName(Path.GetFullPath(options.Output)); + if (!string.IsNullOrEmpty(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + await File.WriteAllTextAsync(options.Output, rendered).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + // Covers cases such as '--output' pointing at an existing directory (unlike + // 'render', where --output is a directory), a read-only/locked target, or an + // otherwise-invalid path, surfacing a clean error instead of an unhandled + // exception with a stack trace. + context.WriteError($"export: failed to write output file '{options.Output}': {ex.Message}"); + return; + } + + context.WriteLine($"export: wrote {declarations.Count} declaration(s), {edges.Count} edge(s), " + + $"{result.Diagnostics.Count} diagnostic(s) to '{options.Output}'."); + } + else + { + context.WriteLine(rendered); + } + } + + /// + /// Renders an as JSON Lines: one compact JSON object per + /// declaration, edge, and diagnostic, each tagged with a "kind" discriminator. + /// + /// The export result to render. + /// The rendered JSONL text, with one record per line. + private static string RenderJsonLines(ExportResult result) + { + var builder = new StringBuilder(); + + foreach (var (qualifiedName, node) in result.Declarations) + { + var line = ExportDeclarationLine.Create(qualifiedName, node); + builder.AppendLine(JsonSerializer.Serialize(line, ExportLineSerializerContext.Default.ExportDeclarationLine)); + } + + foreach (var edge in result.Edges) + { + var line = ExportEdgeLine.Create(edge); + builder.AppendLine(JsonSerializer.Serialize(line, ExportLineSerializerContext.Default.ExportEdgeLine)); + } + + foreach (var diagnostic in result.Diagnostics) + { + var line = ExportDiagnosticLine.Create(diagnostic); + builder.AppendLine(JsonSerializer.Serialize(line, ExportLineSerializerContext.Default.ExportDiagnosticLine)); + } + + // Trim the trailing newline so the rendered text matches the single-document JSON path's + // convention of returning a value with no trailing line terminator. + return builder.ToString().TrimEnd('\r', '\n'); + } + + /// + /// Determines whether a qualified name should be included in the export output, given the + /// --include-stdlib option. + /// + /// The qualified name to check. + /// The loaded workspace, supplying . + /// The --include-stdlib option value. + /// + /// when is , + /// or when is not a standard-library name. + /// + /// + /// Mirrors Query.QueryEngine.IsVisible exactly; replicated here (rather than + /// shared) because this Tool project cannot reference the Core project's internal + /// StdlibFilter, and QueryEngine.IsVisible is . + /// + private static bool IsVisible(string qualifiedName, SysmlWorkspace workspace, bool includeStdlib) => + includeStdlib || !workspace.StdlibNames.Contains(qualifiedName); + + /// + /// Prints help for the export command. + /// + /// The CLI context for output. + /// + /// The single source of truth for both export --help and help export — see + /// and Program.RunAsync's command-aware help + /// dispatch. + /// + public static void PrintHelp(Context context) + { + context.WriteLine(ExportStrings.Export_Usage); + context.WriteLine(""); + context.WriteLine(ExportStrings.Export_Description1); + context.WriteLine(ExportStrings.Export_Description2); + context.WriteLine(""); + context.WriteLine(ExportStrings.Export_OptionsHeader); + context.WriteLine(ExportStrings.Export_OptionFormat); + context.WriteLine(ExportStrings.Export_OptionOutput1); + context.WriteLine(ExportStrings.Export_OptionOutput2); + context.WriteLine(ExportStrings.Export_OptionIncludeStdlib); + } +} diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportLineSerializerContext.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportLineSerializerContext.cs new file mode 100644 index 0000000..2bd6dd6 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportLineSerializerContext.cs @@ -0,0 +1,45 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Text.Json.Serialization; + +namespace DemaConsulting.SysML2Tools.Export; + +/// +/// Source-generator context for serializing the JSONL per-line wrapper records +/// (, , +/// ) to compact, non-indented JSON — one record per line +/// (the --format jsonl path). +/// +/// +/// Kept separate from (which is +/// WriteIndented = true for the single-document --format json path) because +/// source-generated types only support one context-level +/// indentation setting; JSONL's entire value proposition (one compact record per line, safe +/// for line-oriented tools like grep/tail) would be defeated by embedded +/// newlines from an indented serializer. +/// +[JsonSerializable(typeof(ExportDeclarationLine))] +[JsonSerializable(typeof(ExportEdgeLine))] +[JsonSerializable(typeof(ExportDiagnosticLine))] +[JsonSourceGenerationOptions(WriteIndented = false)] +internal partial class ExportLineSerializerContext : JsonSerializerContext +{ +} diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportOptions.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportOptions.cs new file mode 100644 index 0000000..d6e236f --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportOptions.cs @@ -0,0 +1,73 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace DemaConsulting.SysML2Tools.Export; + +/// +/// Immutable set of options parsed for one export command invocation. +/// +/// +/// Mirrors the shape of /: +/// a flags-plus-positional-files record populated by . +/// +internal sealed record ExportOptions +{ + /// + /// Gets the output format, supplied via --format. + /// + /// + /// Accepted values are "json" (default when ) and + /// "jsonl". Captured raw here and validated later by + /// , matching the query/render commands' + /// --format validation style. + /// + public string? Format { get; init; } + + /// + /// Gets the output file path, supplied via --output. + /// + /// + /// Important: unlike + /// (which names an output directory that render writes one file per view + /// into), export's --output names a single output file that the + /// entire JSON/JSONL document is written to. The two commands intentionally reuse the + /// same flag name for a related-but-different meaning (both are "where does the primary + /// output go"); this distinction is called out in the export --help text and the + /// user guide to avoid confusion. means write to stdout via + /// . + /// + public string? Output { get; init; } + + /// + /// Gets a value indicating whether the OMG standard library should be included in the + /// exported declarations/edges, supplied via --include-stdlib. + /// + /// + /// Defaults to (stdlib elements are excluded), mirroring the + /// query command's exact convention. Diagnostics are never stdlib-filtered — see + /// 's remarks. + /// + public bool IncludeStdlib { get; init; } + + /// + /// Gets the file glob patterns supplied as positional arguments. + /// + public IReadOnlyList Files { get; init; } = []; +} diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportResult.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportResult.cs new file mode 100644 index 0000000..3075587 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportResult.cs @@ -0,0 +1,127 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using DemaConsulting.SysML2Tools.Parser; +using DemaConsulting.SysML2Tools.Semantic.Model; + +namespace DemaConsulting.SysML2Tools.Export; + +/// +/// Envelope for the export command's --format json output: the full resolved +/// semantic model (declarations, edges, diagnostics) as a single document. +/// +/// +/// Deliberately reuses // +/// directly rather than introducing a fourth parallel shape — +/// see docs/design/sysml2-tools-tool/export.md for the rationale (this is a bulk, +/// lossless, round-trip-capable dump, the opposite goal from query's narrow +/// summarization shape, so the two are not shared). +/// is a qualified-name-keyed dictionary (not an array) because +/// qualified names are already unique keys, giving an agent O(1) lookup by name without an +/// extra index pass; / have no natural unique key +/// and are therefore arrays. +/// +internal sealed record ExportResult +{ + /// + /// Gets the exported declarations, keyed by fully-qualified name. + /// + /// + /// Excludes OMG standard-library declarations unless --include-stdlib was + /// supplied — see 's stdlib-filtering remarks. + /// + public required IReadOnlyDictionary Declarations { get; init; } + + /// + /// Gets the exported resolved reference edges. + /// + /// + /// Excludes any edge whose source or target is an OMG standard-library qualified name, + /// unless --include-stdlib was supplied. + /// + public required IReadOnlyList Edges { get; init; } + + /// + /// Gets the parse/semantic-resolution diagnostics produced while loading the workspace. + /// + /// + /// Never stdlib-filtered: diagnostics only ever + /// originate from the user's own files, since the stdlib symbol table is a pre-resolved + /// seed loaded via StdlibProvider.GetSymbolTable(), not re-parsed per invocation. + /// + public required IReadOnlyList Diagnostics { get; init; } +} + +/// +/// One JSON Lines record for a declaration, emitted by --format jsonl. +/// +/// Always "declaration"; the JSONL discriminator field. +/// The declaration's fully-qualified name (the dictionary key it +/// would otherwise occupy in ). +/// The declaration node itself (polymorphic; see ). +internal sealed record ExportDeclarationLine(string Kind, string QualifiedName, SysmlNode Node) +{ + /// + /// Creates an for the given qualified name/node pair. + /// + /// The declaration's fully-qualified name. + /// The declaration node. + /// The constructed line record, with set to "declaration". + public static ExportDeclarationLine Create(string qualifiedName, SysmlNode node) => + new("declaration", qualifiedName, node); +} + +/// +/// One JSON Lines record for an edge, emitted by --format jsonl. +/// +/// Always "edge"; the JSONL discriminator field. +/// See . +/// See . +/// See . +internal sealed record ExportEdgeLine(string Kind, string? SourceQualifiedName, string TargetQualifiedName, SysmlEdgeKind EdgeKind) +{ + /// + /// Creates an for the given edge. + /// + /// The edge to flatten into a JSONL line. + /// The constructed line record, with set to "edge". + public static ExportEdgeLine Create(SysmlEdge edge) => + new("edge", edge.SourceQualifiedName, edge.TargetQualifiedName, edge.Kind); +} + +/// +/// One JSON Lines record for a diagnostic, emitted by --format jsonl. +/// +/// Always "diagnostic"; the JSONL discriminator field. +/// See . +/// See . +/// See . +/// See . +/// See . +internal sealed record ExportDiagnosticLine(string Kind, string FilePath, int Line, int Column, DiagnosticSeverity Severity, string Message) +{ + /// + /// Creates an for the given diagnostic. + /// + /// The diagnostic to flatten into a JSONL line. + /// The constructed line record, with set to "diagnostic". + public static ExportDiagnosticLine Create(SysmlDiagnostic diagnostic) => + new("diagnostic", diagnostic.FilePath, diagnostic.Line, diagnostic.Column, diagnostic.Severity, diagnostic.Message); +} diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportResultSerializerContext.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportResultSerializerContext.cs new file mode 100644 index 0000000..c813127 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportResultSerializerContext.cs @@ -0,0 +1,47 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Text.Json.Serialization; +using DemaConsulting.SysML2Tools.Parser; +using DemaConsulting.SysML2Tools.Semantic.Model; + +namespace DemaConsulting.SysML2Tools.Export; + +/// +/// Source-generator context for serializing to a single indented +/// JSON document (the --format json path), mirroring the AOT-safe source-gen pattern +/// used by . +/// +/// +/// A separate, non-indented exists for the +/// --format jsonl path: source-generated types +/// only support a single, context-level +/// setting (there is no per-call override for source-gen contexts), so two contexts is the +/// concrete resolution documented in the export verb's implementation plan, rather than a +/// single shared context or a non-source-gen fallback. +/// +[JsonSerializable(typeof(ExportResult))] +[JsonSerializable(typeof(SysmlNode))] +[JsonSerializable(typeof(SysmlEdge))] +[JsonSerializable(typeof(SysmlDiagnostic))] +[JsonSourceGenerationOptions(WriteIndented = true)] +internal partial class ExportResultSerializerContext : JsonSerializerContext +{ +} diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.cs new file mode 100644 index 0000000..cb50ce4 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.cs @@ -0,0 +1,64 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Resources; + +namespace DemaConsulting.SysML2Tools.Export; + +/// +/// Hand-written, culture-aware accessor for the strings embedded in +/// Export/ExportStrings.resx. See for the rationale behind +/// hand-writing this class instead of relying on the Visual Studio +/// "ResXFileCodeGenerator" custom tool. +/// +/// +/// Adding a future locale requires zero code changes: place an +/// Export/ExportStrings.{culture}.resx file alongside this file with the same key +/// names. +/// +internal static class ExportStrings +{ + private static readonly ResourceManager ResourceManager = + new("DemaConsulting.SysML2Tools.Export.ExportStrings", typeof(ExportStrings).Assembly); + + /// Gets the 'export' command usage line. + public static string Export_Usage => ResourceManager.GetString(nameof(Export_Usage))!; + + /// Gets the first line of the 'export' command description. + public static string Export_Description1 => ResourceManager.GetString(nameof(Export_Description1))!; + + /// Gets the second line of the 'export' command description. + public static string Export_Description2 => ResourceManager.GetString(nameof(Export_Description2))!; + + /// Gets the "Options:" header line. + public static string Export_OptionsHeader => ResourceManager.GetString(nameof(Export_OptionsHeader))!; + + /// Gets the --format option line. + public static string Export_OptionFormat => ResourceManager.GetString(nameof(Export_OptionFormat))!; + + /// Gets the first line of the --output option description. + public static string Export_OptionOutput1 => ResourceManager.GetString(nameof(Export_OptionOutput1))!; + + /// Gets the second line of the --output option description. + public static string Export_OptionOutput2 => ResourceManager.GetString(nameof(Export_OptionOutput2))!; + + /// Gets the --include-stdlib option line. + public static string Export_OptionIncludeStdlib => ResourceManager.GetString(nameof(Export_OptionIncludeStdlib))!; +} diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.resx new file mode 100644 index 0000000..96c1a00 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.resx @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Usage: sysml2tools export [options] <files...> + + + Loads the given SysML v2 / KerML files (and any file glob patterns) and dumps the + + + resolved semantic model (declarations, edges, diagnostics) as JSON or JSON Lines. + + + Options: + + + --format json|jsonl Output format (default: json) + + + --output <file> Output file path (default: stdout). Unlike 'render', + + + this names a FILE, not a directory. + + + --include-stdlib Include OMG standard library elements + + diff --git a/src/DemaConsulting.SysML2Tools.Tool/Program.cs b/src/DemaConsulting.SysML2Tools.Tool/Program.cs index 0cd76a6..32ba54d 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Program.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Program.cs @@ -21,6 +21,7 @@ using System.Globalization; using System.Reflection; using DemaConsulting.SysML2Tools.Cli; +using DemaConsulting.SysML2Tools.Export; using DemaConsulting.SysML2Tools.Help; using DemaConsulting.SysML2Tools.Lint; using DemaConsulting.SysML2Tools.Query; @@ -158,6 +159,10 @@ public static async Task RunAsync(Context context) break; + case SysmlCommand.Export: + ExportCommand.PrintHelp(context); + break; + default: PrintTopLevelHelp(context); break; @@ -207,6 +212,8 @@ internal static void PrintTopLevelHelp(Context context) context.WriteLine(ProgramStrings.TopLevel_CommandQuery1); context.WriteLine(ProgramStrings.TopLevel_CommandQuery2); context.WriteLine(ProgramStrings.TopLevel_CommandQuery3); + context.WriteLine(ProgramStrings.TopLevel_CommandExport1); + context.WriteLine(ProgramStrings.TopLevel_CommandExport2); context.WriteLine(ProgramStrings.TopLevel_CommandHelp1); context.WriteLine(ProgramStrings.TopLevel_CommandHelp2); context.WriteLine(""); @@ -254,6 +261,10 @@ private static async Task RunToolLogicAsync(Context context) await QueryCommand.RunAsync(context).ConfigureAwait(false); break; + case SysmlCommand.Export: + await ExportCommand.RunAsync(context).ConfigureAwait(false); + break; + default: context.WriteLine(ProgramStrings.NoCommand_Message); break; diff --git a/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.cs b/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.cs index 1a7e802..cd4a7f6 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.cs @@ -70,6 +70,12 @@ internal static class ProgramStrings /// Gets the third line of the 'query' command summary. public static string TopLevel_CommandQuery3 => ResourceManager.GetString(nameof(TopLevel_CommandQuery3))!; + /// Gets the first line of the 'export' command summary. + public static string TopLevel_CommandExport1 => ResourceManager.GetString(nameof(TopLevel_CommandExport1))!; + + /// Gets the second line of the 'export' command summary. + public static string TopLevel_CommandExport2 => ResourceManager.GetString(nameof(TopLevel_CommandExport2))!; + /// Gets the first line of the 'help' command summary. public static string TopLevel_CommandHelp1 => ResourceManager.GetString(nameof(TopLevel_CommandHelp1))!; diff --git a/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.resx index 1f3761e..021c2f1 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.resx +++ b/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.resx @@ -85,6 +85,12 @@ 'sysml2tools query --help' for the verb list) + + export [options] <files..> Export the resolved semantic model as JSON or JSON + + + Lines (declarations, edges, diagnostics) + help [command] [verb] Print help for the tool, a command, or (for 'query') diff --git a/src/DemaConsulting.SysML2Tools.Tool/SelfTest/Validation.cs b/src/DemaConsulting.SysML2Tools.Tool/SelfTest/Validation.cs index 83f8580..da0886e 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/SelfTest/Validation.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/SelfTest/Validation.cs @@ -82,6 +82,7 @@ public static async Task RunAsync(Context context) await RunRenderDynamicViewSvgSelfTestAsync(context, testResults).ConfigureAwait(false); await RunRenderDynamicViewPngSelfTestAsync(context, testResults).ConfigureAwait(false); await RunRenderDynamicViewFilteredSelfTestAsync(context, testResults).ConfigureAwait(false); + await RunExportSelfTestAsync(context, testResults).ConfigureAwait(false); // Calculate totals var totalTests = testResults.Results.Count; @@ -668,6 +669,91 @@ private static async Task RunRenderDynamicViewFilteredSelfTestAsync(Context cont FinalizeTestResult(test, startTime, testResults); } + /// + /// Runs an export self-test against the built-in : loads the + /// workspace, builds an exactly as + /// does, serializes it as a single JSON + /// document via , and asserts the JSON + /// round-trips (deserializes) and contains the expected declaration/edge counts. + /// + /// The context for output. + /// The test results collection. + /// + /// Only the --format json path is exercised here. A JSONL self-test variant is + /// not added: both formats share the exact same declaration/edge/diagnostic collection + /// and filtering logic in — only the final + /// serialization step differs (single indented document vs. one compact line per + /// record) — so this JSON self-test already exercises the qualification-relevant + /// workspace-loading/filtering logic; the JSONL-specific serialization shape is covered + /// directly by the dedicated unit/integration tests in + /// test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs + /// instead of being duplicated into the self-test suite. + /// + private static async Task RunExportSelfTestAsync(Context context, DemaConsulting.TestResults.TestResults testResults) + { + var startTime = DateTime.UtcNow; + var test = CreateTestResult("SysML2Tools_ExportSelfTest"); + + try + { + using var tempDir = new TemporaryDirectory(); + var modelFile = PathHelpers.SafePathCombine(tempDir.DirectoryPath, "self-test.sysml"); + + // Write the self-test model to a temporary file + await File.WriteAllTextAsync(modelFile, SelfTestModel).ConfigureAwait(false); + + // Load the model (no stdlib seed, mirroring the other self-tests) + var loadResult = await WorkspaceLoader.LoadAsync([modelFile]).ConfigureAwait(false); + if (loadResult.Workspace is null) + { + test.Outcome = DemaConsulting.TestResults.TestOutcome.Failed; + test.ErrorMessage = "Workspace loading failed"; + context.WriteError($"✗ SysML2Tools_ExportSelfTest - Failed: workspace loading failed"); + FinalizeTestResult(test, startTime, testResults); + return; + } + + var workspace = loadResult.Workspace; + var exportResult = new Export.ExportResult + { + Declarations = workspace.Declarations, + Edges = workspace.Index.AllEdges, + Diagnostics = loadResult.Diagnostics + }; + + var json = System.Text.Json.JsonSerializer.Serialize( + exportResult, Export.ExportResultSerializerContext.Default.ExportResult); + + // Verify the JSON round-trips and contains the expected declarations (SelfTestModel + // declares 'ValidateTest', 'GeneralView', 'SensorUnit', and 'ActuatorUnit'). + using var parsed = System.Text.Json.JsonDocument.Parse(json); + var declarationsElement = parsed.RootElement.GetProperty("Declarations"); + var hasExpectedDeclarations = + declarationsElement.TryGetProperty("ValidateTest", out _) && + declarationsElement.TryGetProperty("ValidateTest::SensorUnit", out _); + + if (hasExpectedDeclarations && exportResult.Declarations.Count > 0) + { + test.Outcome = DemaConsulting.TestResults.TestOutcome.Passed; + context.WriteLine($"✓ SysML2Tools_ExportSelfTest - Passed"); + } + else + { + test.Outcome = DemaConsulting.TestResults.TestOutcome.Failed; + test.ErrorMessage = "Exported JSON did not contain the expected declarations"; + context.WriteError($"✗ SysML2Tools_ExportSelfTest - Failed: exported JSON did not contain the expected declarations"); + } + } + // Generic catch is justified here as this is a test framework - any exception should be + // recorded as a test failure to ensure robust test execution and reporting. + catch (Exception ex) + { + HandleTestException(test, context, "SysML2Tools_ExportSelfTest", ex); + } + + FinalizeTestResult(test, startTime, testResults); + } + /// /// Loads a fresh workspace from , synthesizes a dynamic /// general-kind view targeting the ValidateTest package with the given diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs index b37a659..30c32d9 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs @@ -633,6 +633,99 @@ public void Context_Create_RenderCommand_OutputWithoutValue_ThrowsArgumentExcept Assert.Contains("--output", exception.Message); } + /// + /// Test creating a context with the export command sets Command to SysmlCommand.Export. + /// + [Fact] + public void Context_Create_ExportCommand_SetsCommandExport() + { + // Act: execute the operation being tested + using var context = Context.Create(["export"]); + + // Assert: verify expected behavior + Assert.Equal(SysmlCommand.Export, context.Command); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with export command and --format jsonl sets Export.Format to "jsonl". + /// + [Fact] + public void Context_Create_ExportCommand_WithFormat_SetsFormat() + { + // Act: execute the operation being tested + using var context = Context.Create(["export", "--format", "jsonl"]); + + // Assert: verify expected behavior + Assert.Equal("jsonl", context.Export!.Format); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with export command and --output sets Export.Output. + /// + [Fact] + public void Context_Create_ExportCommand_WithOutput_SetsOutput() + { + // Act: execute the operation being tested + using var context = Context.Create(["export", "--output", "out.json"]); + + // Assert: verify expected behavior + Assert.Equal("out.json", context.Export!.Output); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with export command and --include-stdlib sets Export.IncludeStdlib to true. + /// + [Fact] + public void Context_Create_ExportCommand_WithIncludeStdlibFlag_SetsIncludeStdlibTrue() + { + // Act: execute the operation being tested + using var context = Context.Create(["export", "--include-stdlib"]); + + // Assert: verify expected behavior + Assert.True(context.Export!.IncludeStdlib); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with export command and a file pattern sets Files. + /// + [Fact] + public void Context_Create_ExportCommand_WithFiles_SetsFiles() + { + // Act: execute the operation being tested + using var context = Context.Create(["export", "*.sysml"]); + + // Assert: verify expected behavior + Assert.Single(context.Export!.Files); + Assert.Equal("*.sysml", context.Export.Files[0]); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with export --format but no value throws ArgumentException. + /// + [Fact] + public void Context_Create_ExportCommand_FormatWithoutValue_ThrowsArgumentException() + { + // Act & Assert + var exception = Assert.Throws(() => Context.Create(["export", "--format"])); + Assert.Contains("--format", exception.Message); + } + + /// + /// Test creating a context with export --output but no value throws ArgumentException. + /// + [Fact] + public void Context_Create_ExportCommand_OutputWithoutValue_ThrowsArgumentException() + { + // Act & Assert + var exception = Assert.Throws(() => Context.Create(["export", "--output"])); + Assert.Contains("--output", exception.Message); + } + /// /// Test creating a context with the query command and each of the 11 verb tokens sets /// Command to SysmlCommand.Query and Query.Verb to the matching enum value. diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs new file mode 100644 index 0000000..db0f650 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs @@ -0,0 +1,365 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Text.Json; +using DemaConsulting.SysML2Tools.Export; +using DemaConsulting.SysML2Tools.Parser; +using DemaConsulting.SysML2Tools.Semantic.Model; +using DemaConsulting.SysML2Tools.Utilities; + +namespace DemaConsulting.SysML2Tools.Tests.Export; + +/// +/// JSON/JSONL shape assertions for the Export subsystem: $type discriminator +/// round-tripping, string values, diagnostic field presence, and +/// a full CLI end-to-end integration test exporting a real OMG fixture file. +/// +[Collection("Sequential")] +public class ExportRenderingTests +{ + /// + /// Serializing an containing a + /// produces a "$type": "definition" discriminator, and the JSON round-trips back to + /// an equivalent declaration when deserialized. + /// + [Fact] + public void ExportResultSerializerContext_DefinitionNode_RoundTripsTypeDiscriminator() + { + var node = new SysmlDefinitionNode + { + Name = "Wheel", + QualifiedName = "Model::Wheel", + DefinitionKeyword = "part def" + }; + var result = new ExportResult + { + Declarations = new Dictionary { ["Model::Wheel"] = node }, + Edges = [], + Diagnostics = [] + }; + + var json = JsonSerializer.Serialize(result, ExportResultSerializerContext.Default.ExportResult); + + Assert.Contains("\"$type\": \"definition\"", json); + Assert.Contains("\"DefinitionKeyword\": \"part def\"", json); + + var deserialized = JsonSerializer.Deserialize(json, ExportResultSerializerContext.Default.ExportResult); + Assert.NotNull(deserialized); + var roundTripped = Assert.IsType(deserialized!.Declarations["Model::Wheel"]); + Assert.Equal("part def", roundTripped.DefinitionKeyword); + } + + /// + /// A serializes with a "$type": "feature" + /// discriminator. + /// + [Fact] + public void ExportResultSerializerContext_FeatureNode_UsesFeatureTypeDiscriminator() + { + var node = new SysmlFeatureNode + { + Name = "w", + QualifiedName = "Model::Car::w", + FeatureKeyword = "part", + FeatureTyping = "Model::Wheel" + }; + var result = new ExportResult + { + Declarations = new Dictionary { ["Model::Car::w"] = node }, + Edges = [], + Diagnostics = [] + }; + + var json = JsonSerializer.Serialize(result, ExportResultSerializerContext.Default.ExportResult); + + Assert.Contains("\"$type\": \"feature\"", json); + } + + /// + /// An edge's value serializes as its underlying numeric + /// representation and round-trips to the exact same enum value. + /// + [Theory] + [InlineData(SysmlEdgeKind.Supertype)] + [InlineData(SysmlEdgeKind.Typing)] + [InlineData(SysmlEdgeKind.Satisfy)] + [InlineData(SysmlEdgeKind.Connect)] + [InlineData(SysmlEdgeKind.Transition)] + public void ExportResultSerializerContext_EdgeKind_RoundTripsExactly(SysmlEdgeKind kind) + { + var edge = new SysmlEdge("Model::A", "Model::B", kind); + var result = new ExportResult + { + Declarations = new Dictionary(), + Edges = [edge], + Diagnostics = [] + }; + + var json = JsonSerializer.Serialize(result, ExportResultSerializerContext.Default.ExportResult); + var deserialized = JsonSerializer.Deserialize(json, ExportResultSerializerContext.Default.ExportResult); + + Assert.NotNull(deserialized); + var roundTripped = Assert.Single(deserialized!.Edges); + Assert.Equal(kind, roundTripped.Kind); + Assert.Equal("Model::A", roundTripped.SourceQualifiedName); + Assert.Equal("Model::B", roundTripped.TargetQualifiedName); + } + + /// + /// A diagnostic's fields (FilePath, Line, Column, Severity, Message) are all present in + /// the serialized JSON document. + /// + [Fact] + public void ExportResultSerializerContext_Diagnostic_AllFieldsPresent() + { + var diagnostic = new SysmlDiagnostic("model.sysml", 3, 7, DiagnosticSeverity.Warning, "unresolved reference"); + var result = new ExportResult + { + Declarations = new Dictionary(), + Edges = [], + Diagnostics = [diagnostic] + }; + + var json = JsonSerializer.Serialize(result, ExportResultSerializerContext.Default.ExportResult); + + Assert.Contains("\"FilePath\": \"model.sysml\"", json); + Assert.Contains("\"Line\": 3", json); + Assert.Contains("\"Column\": 7", json); + Assert.Contains("\"Message\": \"unresolved reference\"", json); + } + + /// + /// The JSONL declaration line wraps a "kind": "declaration" discriminator, the qualified + /// name, and the node itself, and serializes on a single compact (non-indented) line. + /// + [Fact] + public void ExportLineSerializerContext_DeclarationLine_HasKindDiscriminatorAndIsCompact() + { + var node = new SysmlDefinitionNode { Name = "Wheel", QualifiedName = "Model::Wheel", DefinitionKeyword = "part def" }; + var line = ExportDeclarationLine.Create("Model::Wheel", node); + + var json = JsonSerializer.Serialize(line, ExportLineSerializerContext.Default.ExportDeclarationLine); + + Assert.DoesNotContain('\n', json); + Assert.Contains("\"Kind\":\"declaration\"", json); + Assert.Contains("\"QualifiedName\":\"Model::Wheel\"", json); + Assert.Contains("\"$type\":\"definition\"", json); + } + + /// + /// The JSONL edge line wraps a "kind": "edge" discriminator and the flattened edge fields, + /// on a single compact line. + /// + [Fact] + public void ExportLineSerializerContext_EdgeLine_HasKindDiscriminatorAndIsCompact() + { + var line = ExportEdgeLine.Create(new SysmlEdge("Model::A", "Model::B", SysmlEdgeKind.Typing)); + + var json = JsonSerializer.Serialize(line, ExportLineSerializerContext.Default.ExportEdgeLine); + + Assert.DoesNotContain('\n', json); + Assert.Contains("\"Kind\":\"edge\"", json); + Assert.Contains("\"SourceQualifiedName\":\"Model::A\"", json); + Assert.Contains("\"TargetQualifiedName\":\"Model::B\"", json); + } + + /// + /// The JSONL diagnostic line wraps a "kind": "diagnostic" discriminator and the flattened + /// diagnostic fields, on a single compact line. + /// + [Fact] + public void ExportLineSerializerContext_DiagnosticLine_HasKindDiscriminatorAndIsCompact() + { + var line = ExportDiagnosticLine.Create( + new SysmlDiagnostic("model.sysml", 1, 0, DiagnosticSeverity.Error, "boom")); + + var json = JsonSerializer.Serialize(line, ExportLineSerializerContext.Default.ExportDiagnosticLine); + + Assert.DoesNotContain('\n', json); + Assert.Contains("\"Kind\":\"diagnostic\"", json); + Assert.Contains("\"Message\":\"boom\"", json); + } + + /// + /// Full CLI end-to-end integration test: runs the built tool via dotnet against a + /// real OMG test fixture (test/SysMLModels/OMG/examples/VehicleExample/VehicleDefinitions.sysml), + /// for both --format json and --format jsonl, and validates the produced + /// output deserializes and contains the expected declarations/edges/diagnostics shape, + /// with stdlib correctly excluded (default) and included (--include-stdlib). + /// + [Fact] + public void ExportIntegration_RealFixture_ProducesValidJsonAndJsonl() + { + var dllPath = PathHelpers.SafePathCombine(AppContext.BaseDirectory, "DemaConsulting.SysML2Tools.dll"); + Assert.True(File.Exists(dllPath), $"Could not find SysML2 Tools DLL at {dllPath}"); + + var fixtureRoot = FindSysMlModelsRoot(); + Assert.NotNull(fixtureRoot); + var fixtureFile = Path.Combine(fixtureRoot!, "OMG", "examples", "VehicleExample", "VehicleDefinitions.sysml"); + Assert.True(File.Exists(fixtureFile), $"Could not find fixture file at {fixtureFile}"); + + // --- JSON, default (stdlib excluded) --- + var exitCodeJson = Runner.Run(out var jsonOutput, "dotnet", dllPath, "export", "--format", "json", fixtureFile); + Assert.Equal(0, exitCodeJson); + + // Strip the banner/status lines preceding the JSON document itself. Searching for the + // first '{' is sufficient here since none of the preceding banner/status lines contain + // that character, and is robust to platform line-ending differences (\n vs \r\n). + var jsonStart = jsonOutput.IndexOf('{'); + Assert.True(jsonStart >= 0, $"Could not find start of JSON document in output:\n{jsonOutput}"); + var jsonDocumentText = jsonOutput[jsonStart..]; + + using (var document = JsonDocument.Parse(jsonDocumentText)) + { + var root = document.RootElement; + Assert.True(root.TryGetProperty("Declarations", out var declarations)); + Assert.True(declarations.EnumerateObject().Any()); + Assert.True(root.TryGetProperty("Edges", out var edges)); + Assert.True(edges.GetArrayLength() > 0); + Assert.True(root.TryGetProperty("Diagnostics", out _)); + + // Stdlib excluded by default: no OMG ScalarValues stdlib declarations present + Assert.DoesNotContain( + declarations.EnumerateObject(), + property => property.Name.StartsWith("ScalarValues", StringComparison.Ordinal)); + } + + // --- JSON, --include-stdlib --- + var exitCodeStdlib = Runner.Run( + out var stdlibOutput, "dotnet", dllPath, "export", "--format", "json", "--include-stdlib", fixtureFile); + Assert.Equal(0, exitCodeStdlib); + Assert.Contains("ScalarValues", stdlibOutput); + Assert.True(stdlibOutput.Length > jsonOutput.Length); + + // --- JSONL --- + var exitCodeJsonl = Runner.Run(out var jsonlOutput, "dotnet", dllPath, "export", "--format", "jsonl", fixtureFile); + Assert.Equal(0, exitCodeJsonl); + + var jsonlLines = jsonlOutput + .Split('\n') + .Select(line => line.Trim()) + .Where(line => line.StartsWith("{\"Kind\"", StringComparison.Ordinal)) + .ToList(); + Assert.NotEmpty(jsonlLines); + + var declarationLines = jsonlLines.Count(line => line.Contains("\"Kind\":\"declaration\"", StringComparison.Ordinal)); + var edgeLines = jsonlLines.Count(line => line.Contains("\"Kind\":\"edge\"", StringComparison.Ordinal)); + Assert.True(declarationLines > 0); + Assert.True(edgeLines > 0); + + // Every JSONL line must be independently parseable as JSON (one record per line) + foreach (var line in jsonlLines) + { + using var lineDocument = JsonDocument.Parse(line); + Assert.True(lineDocument.RootElement.TryGetProperty("Kind", out _)); + } + } + + /// + /// --output pointing at a file inside a directory that doesn't yet exist succeeds, + /// creating the missing parent directory (mirroring render's + /// Directory.CreateDirectory guard for its own --output directory). + /// + [Fact] + public void ExportIntegration_OutputInMissingDirectory_CreatesDirectoryAndSucceeds() + { + var dllPath = PathHelpers.SafePathCombine(AppContext.BaseDirectory, "DemaConsulting.SysML2Tools.dll"); + Assert.True(File.Exists(dllPath), $"Could not find SysML2 Tools DLL at {dllPath}"); + + var fixtureRoot = FindSysMlModelsRoot(); + Assert.NotNull(fixtureRoot); + var fixtureFile = Path.Combine(fixtureRoot!, "OMG", "examples", "VehicleExample", "VehicleDefinitions.sysml"); + Assert.True(File.Exists(fixtureFile), $"Could not find fixture file at {fixtureFile}"); + + var tempRoot = Path.Combine(Path.GetTempPath(), "sysml2tools-export-test-" + Guid.NewGuid().ToString("N")); + var outputFile = Path.Combine(tempRoot, "nested", "out.json"); + try + { + Assert.False(Directory.Exists(Path.GetDirectoryName(outputFile))); + + var exitCode = Runner.Run( + out _, "dotnet", dllPath, "export", "--format", "json", "--output", outputFile, fixtureFile); + + Assert.Equal(0, exitCode); + Assert.True(File.Exists(outputFile)); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + /// + /// --output pointing at an existing directory (rather than a file) fails cleanly + /// with a non-zero exit code and a user-facing error message, instead of an unhandled + /// exception/stack trace. + /// + [Fact] + public void ExportIntegration_OutputIsExistingDirectory_FailsCleanly() + { + var dllPath = PathHelpers.SafePathCombine(AppContext.BaseDirectory, "DemaConsulting.SysML2Tools.dll"); + Assert.True(File.Exists(dllPath), $"Could not find SysML2 Tools DLL at {dllPath}"); + + var fixtureRoot = FindSysMlModelsRoot(); + Assert.NotNull(fixtureRoot); + var fixtureFile = Path.Combine(fixtureRoot!, "OMG", "examples", "VehicleExample", "VehicleDefinitions.sysml"); + Assert.True(File.Exists(fixtureFile), $"Could not find fixture file at {fixtureFile}"); + + var tempDir = Path.Combine(Path.GetTempPath(), "sysml2tools-export-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var exitCode = Runner.Run( + out var output, "dotnet", dllPath, "export", "--format", "json", "--output", tempDir, fixtureFile); + + Assert.NotEqual(0, exitCode); + Assert.Contains("export: failed to write output file", output); + Assert.DoesNotContain("Unhandled exception", output); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// Finds the test/SysMLModels directory relative to the test assembly, mirroring + /// Query.QueryOmgFixtureTests.FindSysMlModelsRoot. + /// + private static string? FindSysMlModelsRoot() + { + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + var candidate = Path.Combine(dir, "test", "SysMLModels"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + dir = Directory.GetParent(dir)?.FullName; + } + + return null; + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportSubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportSubsystemTests.cs new file mode 100644 index 0000000..605cbdb --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportSubsystemTests.cs @@ -0,0 +1,321 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using DemaConsulting.SysML2Tools.Cli; +using DemaConsulting.SysML2Tools.Export; + +namespace DemaConsulting.SysML2Tools.Tests.Export; + +/// +/// Subsystem tests for the Export command: argument parsing (flags/rejection), happy-path +/// dispatch for both formats, stdlib include/exclude, --output file vs. stdout, and the +/// missing-files / invalid-format error paths. +/// +[Collection("Sequential")] +public class ExportSubsystemTests +{ + private const string Fixture = """ + package Model { + part def Root; + part def Mid specializes Root; + part def Wheel; + part def Car { + part w : Wheel; + } + } + """; + + // ---- ExportArgumentParser ---- + + /// + /// Parsing with no flags produces defaults: null Format/Output, IncludeStdlib false, and + /// positional files captured. + /// + [Fact] + public void ExportArgumentParser_NoFlags_ProducesDefaults() + { + var options = ExportArgumentParser.Parse(["a.sysml", "b.sysml"]); + + Assert.Null(options.Format); + Assert.Null(options.Output); + Assert.False(options.IncludeStdlib); + Assert.Equal(["a.sysml", "b.sysml"], options.Files); + } + + /// + /// --format captures its raw value without validating it (validation happens later in + /// ExportCommand.RunAsync). + /// + [Fact] + public void ExportArgumentParser_FormatFlag_CapturesRawValue() + { + var options = ExportArgumentParser.Parse(["--format", "jsonl", "a.sysml"]); + + Assert.Equal("jsonl", options.Format); + Assert.Equal(["a.sysml"], options.Files); + } + + /// + /// --output captures the file path value. + /// + [Fact] + public void ExportArgumentParser_OutputFlag_CapturesValue() + { + var options = ExportArgumentParser.Parse(["--output", "out.json", "a.sysml"]); + + Assert.Equal("out.json", options.Output); + } + + /// + /// --include-stdlib sets the boolean flag. + /// + [Fact] + public void ExportArgumentParser_IncludeStdlibFlag_SetsTrue() + { + var options = ExportArgumentParser.Parse(["--include-stdlib", "a.sysml"]); + + Assert.True(options.IncludeStdlib); + } + + /// + /// An unrecognized '-'-prefixed token is rejected with an ArgumentException naming it. + /// + [Fact] + public void ExportArgumentParser_UnrecognizedFlag_ThrowsArgumentException() + { + var exception = Assert.Throws(() => ExportArgumentParser.Parse(["--bogus"])); + Assert.Contains("--bogus", exception.Message); + Assert.Contains("export", exception.Message); + } + + /// + /// --format with no value throws an ArgumentException. + /// + [Fact] + public void ExportArgumentParser_FormatFlagMissingValue_ThrowsArgumentException() + { + Assert.Throws(() => ExportArgumentParser.Parse(["--format"])); + } + + /// + /// --output with no value throws an ArgumentException. + /// + [Fact] + public void ExportArgumentParser_OutputFlagMissingValue_ThrowsArgumentException() + { + Assert.Throws(() => ExportArgumentParser.Parse(["--output"])); + } + + // ---- ExportCommand happy path ---- + + /// + /// 'export --format json' against a valid fixture dispatches, prints JSON to stdout, and + /// exits 0. + /// + [Fact] + public async Task ExportSubsystem_FormatJson_DispatchesAndPrintsJson() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync(Fixture, "--format", "json"); + + Assert.Equal(0, exitCode); + Assert.Contains("\"Declarations\"", output); + Assert.Contains("\"Edges\"", output); + Assert.Contains("\"Diagnostics\"", output); + Assert.Contains("Model::Wheel", output); + } + + /// + /// 'export' with no --format defaults to json. + /// + [Fact] + public async Task ExportSubsystem_NoFormat_DefaultsToJson() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync(Fixture); + + Assert.Equal(0, exitCode); + Assert.Contains("\"Declarations\"", output); + } + + /// + /// 'export --format jsonl' against a valid fixture dispatches and prints JSONL lines (one + /// compact JSON object per declaration/edge/diagnostic) to stdout. + /// + [Fact] + public async Task ExportSubsystem_FormatJsonl_DispatchesAndPrintsJsonLines() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync(Fixture, "--format", "jsonl"); + + Assert.Equal(0, exitCode); + Assert.Contains("\"kind\":\"declaration\"", output.Replace(" ", "", StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase); + Assert.Contains("Model::Wheel", output); + } + + /// + /// Without --include-stdlib, stdlib declarations are excluded from the exported output. + /// + [Fact] + public async Task ExportSubsystem_NoIncludeStdlib_ExcludesStdlib() + { + var (output, _) = await ExportTestFixtures.RunExportAsync(Fixture, "--format", "json"); + + // Without stdlib inclusion, no output is produced from files under 'test/SysMLModels', and + // the default OMG stdlib root packages are excluded. + Assert.DoesNotContain("ScalarValues", output); + } + + /// + /// With --include-stdlib, the exported output is substantially larger and includes stdlib + /// declarations, confirming the flag has an observable effect. + /// + [Fact] + public async Task ExportSubsystem_IncludeStdlib_IncludesStdlibAndIncreasesSize() + { + var (withoutStdlib, exitCode1) = await ExportTestFixtures.RunExportAsync(Fixture, "--format", "json"); + var (withStdlib, exitCode2) = await ExportTestFixtures.RunExportAsync(Fixture, "--format", "json", "--include-stdlib"); + + Assert.Equal(0, exitCode1); + Assert.Equal(0, exitCode2); + Assert.True(withStdlib.Length > withoutStdlib.Length); + Assert.Contains("ScalarValues", withStdlib); + } + + /// + /// --output writes the export document to the given file path instead of stdout, and + /// stdout instead reports a short summary line. + /// + [Fact] + public async Task ExportSubsystem_OutputFlag_WritesToFileInsteadOfStdout() + { + var outputFile = Path.Combine(Path.GetTempPath(), $"export_test_{Guid.NewGuid():N}.json"); + try + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + Fixture, "--format", "json", "--output", outputFile); + + Assert.Equal(0, exitCode); + Assert.True(File.Exists(outputFile)); + + var fileContent = await File.ReadAllTextAsync(outputFile, TestContext.Current.CancellationToken); + Assert.Contains("\"Declarations\"", fileContent); + Assert.Contains("Model::Wheel", fileContent); + + // stdout should not itself contain the full JSON document (it was written to the file) + Assert.DoesNotContain("\"Declarations\"", output); + Assert.Contains("wrote", output); + } + finally + { + if (File.Exists(outputFile)) + { + File.Delete(outputFile); + } + } + } + + // ---- error paths ---- + + /// + /// No input files reports a "no input files" error and exit code 1. + /// + [Fact] + public async Task ExportSubsystem_NoFiles_ReportsNoInputFilesError() + { + var originalError = Console.Error; + try + { + using var errWriter = new StringWriter(); + Console.SetError(errWriter); + + using var context = Context.Create(["export"]); + await ExportCommand.RunAsync(context); + + Assert.Equal(1, context.ExitCode); + Assert.Contains("no input files", errWriter.ToString()); + } + finally + { + Console.SetError(originalError); + } + } + + /// + /// No files matching the given pattern reports a "no files matched" error and exit code 1. + /// + [Fact] + public async Task ExportSubsystem_NoMatchingFiles_ReportsNoFilesMatchedError() + { + var originalError = Console.Error; + try + { + using var errWriter = new StringWriter(); + Console.SetError(errWriter); + + using var context = Context.Create(["export", $"nonexistent_{Guid.NewGuid():N}.sysml"]); + await ExportCommand.RunAsync(context); + + Assert.Equal(1, context.ExitCode); + Assert.Contains("no files matched", errWriter.ToString()); + } + finally + { + Console.SetError(originalError); + } + } + + /// + /// An unsupported --format value throws ArgumentException up front, before any file + /// loading is attempted. + /// + [Fact] + public async Task ExportSubsystem_InvalidFormat_ThrowsArgumentException() + { + using var context = Context.Create(["export", "--format", "yaml", "a.sysml"]); + + var exception = await Assert.ThrowsAsync(() => ExportCommand.RunAsync(context)); + Assert.Contains("yaml", exception.Message); + Assert.Contains("--format", exception.Message); + } + + /// + /// 'export --help' prints help without throwing. + /// + [Fact] + public async Task ExportSubsystem_ExportHelp_PrintsHelpWithoutThrowing() + { + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + + using var context = Context.Create(["export", "--help"]); + await Program.RunAsync(context); + + Assert.Contains("export", outWriter.ToString()); + Assert.Contains("--format", outWriter.ToString()); + Assert.Contains("--include-stdlib", outWriter.ToString()); + Assert.Equal(0, context.ExitCode); + } + finally + { + Console.SetOut(originalOut); + } + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportTestFixtures.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportTestFixtures.cs new file mode 100644 index 0000000..b356643 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportTestFixtures.cs @@ -0,0 +1,73 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using DemaConsulting.SysML2Tools.Cli; + +namespace DemaConsulting.SysML2Tools.Tests.Export; + +/// +/// Shared test helper for the Export subsystem test suites: writes an inline SysML fixture to +/// a temp file and runs an export invocation through , +/// capturing stdout and the resulting exit code. +/// +internal static class ExportTestFixtures +{ + /// + /// Writes to a uniquely-named temp .sysml file, runs + /// export with the given arguments (the temp file path is appended automatically), + /// and returns the captured stdout and exit code. The temp file is deleted afterward. + /// + /// The inline SysML source to write to a temp file. + /// The export arguments, e.g. ["--format", "jsonl"]. + /// + /// The captured stdout text (with any stderr diagnostics appended) and the resulting + /// . + /// + public static async Task<(string Output, int ExitCode)> RunExportAsync(string sysml, params string[] args) + { + var tempFile = Path.GetTempFileName() + ".sysml"; + await File.WriteAllTextAsync(tempFile, sysml, TestContext.Current.CancellationToken); + + var originalOut = Console.Out; + var originalError = Console.Error; + try + { + using var outWriter = new StringWriter(); + using var errWriter = new StringWriter(); + Console.SetOut(outWriter); + Console.SetError(errWriter); + + var fullArgs = new List { "export" }; + fullArgs.AddRange(args); + fullArgs.Add(tempFile); + + using var context = Context.Create([.. fullArgs]); + await Program.RunAsync(context); + + return (outWriter.ToString() + errWriter, context.ExitCode); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + File.Delete(tempFile); + } + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs index 2ffea51..fc37ed5 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs @@ -22,6 +22,7 @@ using System.Reflection; using System.Resources; using DemaConsulting.SysML2Tools; +using DemaConsulting.SysML2Tools.Export; using DemaConsulting.SysML2Tools.Lint; using DemaConsulting.SysML2Tools.Query; using DemaConsulting.SysML2Tools.Render; @@ -31,7 +32,8 @@ namespace DemaConsulting.SysML2Tools.Tests.Resources; /// /// Reflection-based tests proving that each hand-written XxxStrings resource /// accessor class (, , -/// , ) is fully and bidirectionally +/// , , ) is +/// fully and bidirectionally /// wired to its companion .resx file: every resx key resolves to non-empty text, /// and every resx key has a matching accessor property (and vice versa), so the two /// cannot silently drift apart. @@ -53,7 +55,8 @@ public class ResxResourceTests { "DemaConsulting.SysML2Tools.ProgramStrings", typeof(ProgramStrings) }, { "DemaConsulting.SysML2Tools.Lint.LintStrings", typeof(LintStrings) }, { "DemaConsulting.SysML2Tools.Render.RenderStrings", typeof(RenderStrings) }, - { "DemaConsulting.SysML2Tools.Query.QueryStrings", typeof(QueryStrings) } + { "DemaConsulting.SysML2Tools.Query.QueryStrings", typeof(QueryStrings) }, + { "DemaConsulting.SysML2Tools.Export.ExportStrings", typeof(ExportStrings) } }; /// diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/SelfTest/ValidationTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/SelfTest/ValidationTests.cs index c1c9553..b640e71 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/SelfTest/ValidationTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/SelfTest/ValidationTests.cs @@ -245,6 +245,35 @@ public async Task Validation_RunRenderSvgSelfTest_ValidModel_Passes() } } + /// + /// Test that the export self-test passes for the built-in self-test model. + /// + [Fact] + public async Task Validation_RunExportSelfTest_ValidModel_Passes() + { + // Arrange: capture validation output via log file + var logFile = Path.Combine(Path.GetTempPath(), $"validation_test_{Guid.NewGuid()}.log"); + try + { + using (var context = Context.Create(["--silent", "--log", logFile])) + { + // Act: run the full validation suite (includes export self-test) + await Validation.RunAsync(context); + } + + // Assert: the export self-test produced a pass marker in the log + var logContent = await File.ReadAllTextAsync(logFile, TestContext.Current.CancellationToken); + Assert.Contains("✓ SysML2Tools_ExportSelfTest", logContent); + } + finally + { + if (File.Exists(logFile)) + { + File.Delete(logFile); + } + } + } + /// /// Test that the PNG render self-test passes (or is skipped) when SkiaSharp is available. ///