diff --git a/README.md b/README.md index bbc237a..6f1110b 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,9 @@ sysml2tools export "src/**/*.sysml" --format jsonl --output model.jsonl # Include OMG standard library declarations/edges in the export sysml2tools export model.sysml --include-stdlib + +# Restrict output to a containment subtree, then narrow it with a filter expression +sysml2tools export model.sysml --target Vehicle::Engine --filter "@Deprecated" ``` See [Exporting](docs/user_guide/introduction.md#exporting) in the user guide for the full @@ -263,6 +266,8 @@ sysml2tools help [lint|render|query []|export] | `--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) | +| `--target ` | Restrict output to the containment subtree rooted at this element (before `--filter`) | +| `--filter ` | Narrow output using a Phase 1 filter expression (see `render`'s `--filter`); after `--target` | ### `help` Options diff --git a/ROADMAP.md b/ROADMAP.md index 56ce2fe..86fd405 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -224,9 +224,15 @@ compatibility-check gap is carried forward as a known limitation in the "View dy 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`, +[--target ] [--filter ] ` 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. `--target` +restricts output to a single element's containment subtree (expanding a usage/feature +target to its resolved type's subtree too), and `--filter` narrows the (optionally +`--target`-scoped) declarations/edges using the same Phase 1 filter-expression subset +`render`'s dynamic-view `--filter` uses, composing `--target`-then-`--filter` — a graceful, +non-aborting warning/diagnostic is surfaced for an unparseable `--filter` expression rather +than failing the export. 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. diff --git a/docs/design/sysml2-tools-tool/export.md b/docs/design/sysml2-tools-tool/export.md index 65ea79c..d9c847e 100644 --- a/docs/design/sysml2-tools-tool/export.md +++ b/docs/design/sysml2-tools-tool/export.md @@ -9,15 +9,17 @@ a targeted analysis question into a small `QueryResult`, `export` is a lossless, 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 +- `ExportOptions` — an immutable record capturing `Format`, `Output`, `IncludeStdlib`, + `Target`, `FilterExpression`, and `Files`, parsed by `Context.Create` for one `export` + invocation. +- `ExportArgumentParser` — parses `--format`, `--output`, `--include-stdlib`, `--target`, + `--filter`, 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. + Loads the workspace, applies the `--include-stdlib` filter, then `--target` subtree scoping + and `--filter` expression narrowing (see **Target Scoping** and **Filter Narrowing** + below), 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. @@ -39,11 +41,24 @@ cooperating types: 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`, +3. When `--target` is supplied: resolves its containment-subtree "subject" qualified names + (see **Target Scoping** below), reporting a clean "not found" error and returning (no + export produced) when the target does not resolve to a visible declaration; otherwise + narrows `Declarations`/`Edges` (from steps 1–2) to that subtree, requiring — for edges — + both endpoints (when the source is non-null) to lie within it. +4. When `--filter` is supplied: parses it via `FilterExpressionParser.Parse`; on success, + narrows the (possibly `--target`-scoped) `Declarations` to the matched subset via + `FilterExpressionEvaluator.Evaluate`, and re-derives `Edges` to keep only edges whose + endpoints (when the source is non-null) both survive in the narrowed declaration set (see + **Filter Narrowing** below); on parse failure, appends a synthetic warning + `SysmlDiagnostic` to the diagnostics list and prints a matching console warning, falling + back to the unfiltered (but still `--target`-scoped, if applicable) result. +5. Copies `workspace.Diagnostics` (plus any synthetic `--filter`-failure diagnostic from step + 4) into `ExportResult.Diagnostics` — diagnostics are never stdlib-filtered (see **Stdlib + Filtering** below). +6. 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 +7. Writes the rendered text to the file named by `--output` (via `File.WriteAllText`) when present, or to stdout via `context.WriteLine` otherwise. ##### Stdlib Filtering @@ -62,6 +77,64 @@ the stdlib symbol table returned by `StdlibProvider.GetSymbolTable()` is a pre-r consumed as-is by `WorkspaceLoader`, not re-parsed, so it can never itself be a diagnostic source. +##### Target Scoping + +`--target ` restricts export output to the containment subtree rooted at the +named element. `ExportCommand.ResolveTargetSubtreeSubjects` resolves the target: it returns +`null` when the name is absent from `workspace.Declarations`, or is present but fails +`IsVisible` (a standard-library declaration without `--include-stdlib`) — both cases are +reported by the caller as the same "was not found in the workspace" error, matching +`IsVisible`'s existing exclude semantics used elsewhere in this class. When the target +resolves, the "subject" set starts as `{ target }`; if the target's declaration is a +`SysmlFeatureNode` (a usage, e.g. `part myVehicle : Vehicle;`), the usage's own resolved +`SysmlEdgeKind.Typing` edge target (its type) is added to the subject set too, so a usage +target still yields useful subtree content instead of a near-empty result. A qualified name +is then "in scope" when it equals a subject or has a subject as a `"{subject}::"` prefix +(`ExportCommand.IsInTargetSubtree`). + +This mirrors Core's internal `DemaConsulting.SysML2Tools.Layout.Internal.ExposeScopeResolver` +— specifically its `AddWholeSubtreeSubject` usage-to-type expansion — which the Tool project +cannot reference directly (it is `internal` to Core, and Core's `InternalsVisibleTo` list +only grants Core's own test project, not this Tool project). `ExportCommand` therefore +duplicates a small, purpose-built subset of that logic locally, following the exact same +duplication precedent already established by `IsVisible` above: `--target` only ever has a +single target and no per-target bracket filter (the standalone `--filter` option already +covers narrowing), so `ExposeScopeResolver`'s multi-target/bracket-filter/`Failures` +machinery is deliberately omitted from this smaller copy. Future changes to +`ExposeScopeResolver`'s whole-subtree-subject behavior should prompt a deliberate check of +whether this duplicate needs a matching update. + +##### Filter Narrowing + +`--filter ` narrows the exported declarations/edges using the same Phase 1 +filter-expression subset `render`'s dynamic-view `--filter` uses, reusing the public +`DemaConsulting.SysML2Tools.Filtering.FilterExpressionParser`/`FilterExpressionEvaluator` +types directly (both are `public`, so — unlike the target-subtree logic above — no +duplication is needed here). `ExportCommand.RunAsync` parses `options.FilterExpression` via +`FilterExpressionParser.Parse`; on success, it evaluates the parsed expression against the +current (possibly `--target`-scoped) declaration keys via +`FilterExpressionEvaluator.Evaluate`, narrows `Declarations` to the matched subset, and +re-derives `Edges` to keep only edges whose endpoints (when the source is non-null) both +remain in the matched set. + +A parse failure does not abort the export: mirroring `GeneralViewLayoutStrategy`'s own +graceful degradation for a non-evaluatable `filter [];` statement, `ExportCommand` falls +back to the unfiltered (but still `--target`-scoped, if applicable) result, and surfaces the +failure via two channels — (a) a synthetic `SysmlDiagnostic` appended to +`ExportResult.Diagnostics`, with `FilePath = "<--filter>"` (following the same `[stdlib]…` +virtual-path convention already used for non-ordinary `FilePath` values — see **Stdlib +Filtering** above), `Line = 0`, `Column = 0`, `Severity = DiagnosticSeverity.Warning`, and a +`Message` describing the failure, so the failure is visible to offline/agent JSON/JSONL +consumers; and (b) a matching `context.WriteLine` console warning, mirroring `render`'s own +`--filter` warning-surfacing convention, for interactive visibility. +`FilterExpressionEvaluator.Evaluate` itself never fails (its `Diagnostics` list is always +empty by design), so the single failure point handled here is the parse step. + +Composition order is deliberate: `--target` scopes first, `--filter` narrows second — mirroring +`GeneralViewLayoutStrategy`'s `expose`-then-`filter` pipeline exactly. With no `--target`, +`--filter` narrows the whole (stdlib-filtered) workspace, matching a view with a `filter` +statement but no `expose` statement. + #### Output Model ##### ExportResult Data Model @@ -115,6 +188,12 @@ implemented directly without re-spiking. 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. +- `Target` (`string?`) — a qualified name from `--target`, restricting output to that + element's containment subtree; `null` means no target scoping (the whole stdlib-filtered + workspace). See **Target Scoping** above. +- `FilterExpression` (`string?`) — a raw Phase 1 filter-expression text from `--filter`, + narrowing the (possibly `--target`-scoped) declaration/edge set; `null` means no filtering. + See **Filter Narrowing** above. - `Files` (`IReadOnlyList`) — file glob patterns. #### ExportCommand @@ -132,11 +211,15 @@ implemented directly without re-spiking. 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. +6. Builds the filtered `ExportResult` per **Command Semantics** above, including `--target` + subtree scoping (`WriteError`s "--target '' was not found in the workspace" and + returns — no export produced — when the target does not resolve) and `--filter` narrowing + (falling back to the unfiltered, `--target`-scoped result with a diagnostic and console + warning on parse failure, rather than aborting). 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 +**`PrintHelp(Context context)`**: Prints the `export` usage line, its five options (with an explicit note that `--output` names a file, not a directory), and one example invocation, sourced from `ExportStrings`. @@ -156,6 +239,15 @@ the rationale). - Patterns given but none matched any files: `context.WriteError`; `Context.ExitCode` becomes 1. - Workspace failed to load: `context.WriteError`; `Context.ExitCode` becomes 1. +- `--target` does not resolve to a visible declaration (genuinely absent, or a + standard-library declaration without `--include-stdlib`): `context.WriteError` reporting + "--target '' was not found in the workspace"; `Context.ExitCode` becomes 1; no export + is produced. Both "absent" and "stdlib-hidden" cases intentionally share this one message — + see **Target Scoping** above. +- `--filter` fails to parse/evaluate: not a hard error — falls back to the unfiltered + (`--target`-scoped, if applicable) result, with a synthetic warning `SysmlDiagnostic` + appended to the output and a matching `context.WriteLine` console warning; `Context.ExitCode` + remains 0. See **Filter Narrowing** above. #### Dependencies @@ -185,4 +277,6 @@ the rationale). | 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-Target | `ExportOptions.Target`; `ResolveTargetSubtreeSubjects`/`IsInTargetSubtree` | +| SysML2Tools-Tool-Export-Filter | `ExportOptions.FilterExpression`; `FilterExpressionParser`/`Evaluator` | | SysML2Tools-Tool-Export-SelfTest | `Validation.RunExportSelfTestAsync` | diff --git a/docs/reqstream/sysml2-tools-tool/export.yaml b/docs/reqstream/sysml2-tools-tool/export.yaml index 59c558a..2cb7bc9 100644 --- a/docs/reqstream/sysml2-tools-tool/export.yaml +++ b/docs/reqstream/sysml2-tools-tool/export.yaml @@ -15,9 +15,9 @@ sections: 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. + The export command shall accept --format, --output, --include-stdlib, --target, and + --filter 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 @@ -27,9 +27,13 @@ sections: - ExportArgumentParser_FormatFlag_CapturesRawValue - ExportArgumentParser_OutputFlag_CapturesValue - ExportArgumentParser_IncludeStdlibFlag_SetsTrue + - ExportArgumentParser_TargetFlag_CapturesValue + - ExportArgumentParser_FilterFlag_CapturesValue - ExportArgumentParser_UnrecognizedFlag_ThrowsArgumentException - ExportArgumentParser_FormatFlagMissingValue_ThrowsArgumentException - ExportArgumentParser_OutputFlagMissingValue_ThrowsArgumentException + - ExportArgumentParser_TargetFlagMissingValue_ThrowsArgumentException + - ExportArgumentParser_FilterFlagMissingValue_ThrowsArgumentException - id: SysML2Tools-Tool-Export-Format title: >- @@ -131,6 +135,52 @@ sections: tests: - ExportSubsystem_ExportHelp_PrintsHelpWithoutThrowing + - id: SysML2Tools-Tool-Export-Target + title: >- + The export command shall accept --target restricting output to the + containment subtree rooted at that element (expanding a usage/feature target to also + include its resolved type's subtree), reporting a clear "not found" error — with no + export produced — when the name does not resolve to a visible declaration (either + genuinely absent, or a standard-library declaration without --include-stdlib). + justification: | + A single-target containment-subtree scope lets an agent/CLI consumer request a + focused slice of a large workspace instead of always exporting the whole model, + mirroring the subtree-scoping semantics already established by render's `expose` + statement handling (Core's ExposeScopeResolver), applied here as a small, purpose- + built duplicate since the Tool project cannot reference that Core-internal type. + tests: + - ExportSubsystem_TargetFlag_RestrictsToSubtree + - ExportSubsystem_TargetFlag_IncludesEdgesWithBothEndpointsInSubtree + - ExportSubsystem_TargetFlag_UnresolvedName_ReportsNotFoundError + - ExportSubsystem_TargetFlag_StdlibTargetWithoutIncludeStdlib_ReportsNotFoundError + - ExportSubsystem_TargetFlag_StdlibTargetWithIncludeStdlib_Succeeds + - Context_Create_ExportCommand_WithTarget_SetsTarget + - ExportIntegration_RealFixture_TargetAndFilter_ProducesScopedJson + + - id: SysML2Tools-Tool-Export-Filter + title: >- + The export command shall accept --filter narrowing exported declarations and + edges using the Phase 1 filter-expression subset (Core.Filtering), applied after + --target scoping (narrowing the target-scoped set further when both are supplied, or + the whole stdlib-filtered workspace when --target is absent); an unparsable or + unsupported --filter expression shall not abort the export — it shall fall back to + the unfiltered (but still target-scoped, if applicable) result, surfaced via both a + synthetic warning diagnostic in the output and a console warning. + justification: | + Reusing the public Core.Filtering FilterExpressionParser/FilterExpressionEvaluator + directly (no duplication needed, unlike --target's subtree logic) keeps export's + filter semantics identical to render's `--filter`; falling back to the unfiltered + result on parse failure (rather than aborting) mirrors GeneralViewLayoutStrategy's + own graceful degradation for a non-evaluatable `filter [];` statement, so a + typo in the expression never turns a working export invocation into a hard failure. + tests: + - ExportSubsystem_FilterFlag_NarrowsDeclarations + - ExportSubsystem_FilterFlag_WithoutTarget_NarrowsWholeWorkspace + - ExportSubsystem_TargetAndFilter_ComposeTargetFirstThenFilter + - ExportSubsystem_FilterFlag_UnsupportedConstruct_AddsDiagnosticAndWarns + - Context_Create_ExportCommand_WithFilter_SetsFilterExpression + - ExportIntegration_RealFixture_TargetAndFilter_ProducesScopedJson + - id: SysML2Tools-Tool-Export-SelfTest title: >- The self-validation suite (--validate) shall include an export self-test that loads diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 1a6584c..6362282 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -435,6 +435,9 @@ sysml2tools export "src/**/*.sysml" --format jsonl --output model.jsonl # Include OMG standard library declarations/edges in the export sysml2tools export "src/**/*.sysml" --include-stdlib + +# Restrict output to a containment subtree, then narrow it with a filter expression +sysml2tools export "src/**/*.sysml" --target Vehicle::Engine --filter "@Deprecated" ``` ## `export` Options @@ -445,6 +448,44 @@ sysml2tools export "src/**/*.sysml" --include-stdlib | `--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 | +| `--target ` | Restrict output to the containment subtree rooted at this element | +| `--filter ` | Narrow output using a Phase 1 filter expression | + +## Target Scoping and Filter Narrowing + +`--target ` and `--filter ` compose the same way `render`'s dynamic-view +`--view-target`/`--filter` pair does (see "Dynamic (Ad-Hoc) Views" above), but for `export` +instead of rendering: `--target` scopes the export to one element's containment subtree, +`--filter` narrows the declaration/edge set using the same Phase 1 filter-expression subset +(classification tests, boolean connectives, `(as Type).attribute` reads) — and, when both are +supplied, `--target` is applied **first**, with `--filter` narrowing the already-scoped result +**second**: + +```bash +# Only the Engine subtree +sysml2tools export "src/**/*.sysml" --target Vehicle::Engine + +# The whole workspace, narrowed to elements carrying @Deprecated +sysml2tools export "src/**/*.sysml" --filter @Deprecated + +# The Engine subtree, further narrowed to elements carrying @Deprecated +sysml2tools export "src/**/*.sysml" --target Vehicle::Engine --filter @Deprecated +``` + +- If `--target` names a usage/feature (e.g. `part myEngine : Engine;`) rather than a + definition, its resolved type's subtree is included too, so scoping to a usage still yields + useful content instead of a near-empty result. +- An unresolvable `--target` (not present in the workspace, or a standard-library element + without `--include-stdlib`) reports a clean `export: --target '' was not found in the + workspace.` error and produces no export — both cases share the same message. +- An unparsable or unsupported `--filter` expression does **not** abort the export: it falls + back to the unfiltered (still `--target`-scoped, if applicable) result, appending a synthetic + warning diagnostic (`"FilePath": "<--filter>"`) to the output's `Diagnostics` array and + printing a matching `export: warning: ...` console message — the same graceful-degradation + behavior views apply to a non-evaluatable `filter [];` statement. +- Exported edges always require both endpoints (source and target, when the source is + non-null) to survive every active narrowing step (stdlib filtering, `--target` scoping, and + `--filter` matching) — never just one endpoint. ## Export Output Shape diff --git a/docs/verification/sysml2-tools-tool/export.md b/docs/verification/sysml2-tools-tool/export.md index b235390..a7b7628 100644 --- a/docs/verification/sysml2-tools-tool/export.md +++ b/docs/verification/sysml2-tools-tool/export.md @@ -43,6 +43,22 @@ shape. Tests run against all three target frameworks. 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`). +- `--target ` restricts the exported declarations/edges to the target's + containment subtree (expanding a usage/feature target to also include its resolved type's + subtree); edges require both endpoints (when the source is non-null) to lie within the + subtree. +- An unresolvable `--target` (genuinely absent, or a standard-library declaration without + `--include-stdlib`) reports "--target '' was not found in the workspace" with exit + code 1 and no export produced; both cases share the same message. +- `--filter ` narrows the exported declarations/edges using the Phase 1 + filter-expression subset, applied after `--target` scoping (or over the whole + stdlib-filtered workspace when `--target` is absent). +- An unparsable/unsupported `--filter` expression does not abort the export: it falls back + to the unfiltered (`--target`-scoped, if applicable) result, appending a synthetic warning + `SysmlDiagnostic` (`FilePath = "<--filter>"`) to the output and printing a matching console + warning, with exit code 0. +- `--target` and `--filter` compose in order — `--target` scopes first, `--filter` narrows + the already-scoped set second — never the reverse. - The `SysML2Tools_ExportSelfTest` self-test (part of `--validate`) passes. - Existing `lint`/`render`/`query` test suites continue to pass unmodified, confirming no regression. @@ -70,6 +86,14 @@ globs produces `Format = null`, `Output = null`, `IncludeStdlib = false`. **`ExportArgumentParser_OutputFlagMissingValue_ThrowsArgumentException`**: Verifies a trailing `--format`/`--output` with no following value throws `ArgumentException`. +**`ExportArgumentParser_TargetFlag_CapturesValue`** / +**`ExportArgumentParser_FilterFlag_CapturesValue`**: Verifies `--target ` / +`--filter ` populate `Target`/`FilterExpression` with the raw supplied value. + +**`ExportArgumentParser_TargetFlagMissingValue_ThrowsArgumentException`** / +**`ExportArgumentParser_FilterFlagMissingValue_ThrowsArgumentException`**: Verifies a +trailing `--target`/`--filter` with no following value throws `ArgumentException`. + **`ExportSubsystem_FormatJson_DispatchesAndPrintsJson`** / **`ExportSubsystem_NoFormat_DefaultsToJson`** / **`ExportSubsystem_FormatJsonl_DispatchesAndPrintsJsonLines`**: Verifies each accepted @@ -93,7 +117,41 @@ declarations/edges are excluded by default and included (with measurably larger 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. +help text (including the `--output` file-vs-directory clarification, and the new `--target`/ +`--filter` option lines) and exits with code 0. + +**`ExportSubsystem_TargetFlag_RestrictsToSubtree`**: Verifies `--target` narrows the +exported declarations to the target's containment subtree, excluding unrelated declarations. + +**`ExportSubsystem_TargetFlag_IncludesEdgesWithBothEndpointsInSubtree`**: Verifies that a +usage/feature `--target` value's usage-to-type expansion brings its resolved type into scope, +so the usage's own `Typing` edge (whose endpoints are both then in scope) survives, while an +edge to an unrelated declaration does not. + +**`ExportSubsystem_TargetFlag_UnresolvedName_ReportsNotFoundError`**: Verifies a `--target` +value absent from the workspace reports the "not found" error with exit code 1. + +**`ExportSubsystem_TargetFlag_StdlibTargetWithoutIncludeStdlib_ReportsNotFoundError`** / +**`ExportSubsystem_TargetFlag_StdlibTargetWithIncludeStdlib_Succeeds`**: Verifies a `--target` +value naming a standard-library declaration reports the same "not found" error without +`--include-stdlib`, and succeeds (scoping to that stdlib element's subtree) with it. + +**`ExportSubsystem_FilterFlag_NarrowsDeclarations`**: Verifies `--filter` narrows the +exported declarations to those matching the supplied classification-test expression. + +**`ExportSubsystem_FilterFlag_WithoutTarget_NarrowsWholeWorkspace`**: Verifies `--filter` +with no `--target` narrows the whole (stdlib-filtered) workspace, producing measurably +smaller output than the unfiltered baseline. + +**`ExportSubsystem_TargetAndFilter_ComposeTargetFirstThenFilter`**: Verifies `--target` and +`--filter` compose in order — `--target` scopes to the subtree (including a usage's expanded +type) first, and `--filter` then narrows that already-scoped set further, so only elements +satisfying both narrowing steps survive. + +**`ExportSubsystem_FilterFlag_UnsupportedConstruct_AddsDiagnosticAndWarns`**: Verifies an +unsupported Phase 1 `--filter` construct does not abort the export — it falls back to the +unfiltered result, appends a synthetic warning `SysmlDiagnostic` (`FilePath = "<--filter>"`, +`Severity = Warning`) to the output, and prints a matching console warning. ##### ExportRenderingTests.cs @@ -124,6 +182,14 @@ line, each parseable and carrying the expected `"Kind"` discriminator. Satisfies `SysML2Tools-Tool-Export-StdlibFilter`, `SysML2Tools-Tool-Export-JsonEnvelope`, and `SysML2Tools-Tool-Export-JsonlEnvelope`. +**`ExportIntegration_RealFixture_TargetAndFilter_ProducesScopedJson`**: A full CLI +end-to-end test (via `Runner.Run`) against the same `VehicleDefinitions.sysml` fixture, +supplying both `--target VehicleDefinitions::Vehicle` and a supported (always-matching) +`--filter` expression together: asserts the resulting JSON document is valid, strictly +smaller than an unscoped baseline export, contains no synthetic `--filter`-failure +diagnostic, and that every declaration key and edge endpoint lies within the `--target` +subtree. Satisfies `SysML2Tools-Tool-Export-Target` and `SysML2Tools-Tool-Export-Filter`. + ##### Validation_RunExportSelfTest_ValidModel_Passes (ValidationTests.cs) Verifies that `Validation.RunExportSelfTestAsync` passes against the built-in self-test @@ -144,6 +210,12 @@ populates `Export.Format`. **`Context_Create_ExportCommand_WithIncludeStdlibFlag_SetsIncludeStdlibTrue`**: Verifies that `--include-stdlib` sets `Export.IncludeStdlib` to `true`. +**`Context_Create_ExportCommand_WithTarget_SetsTarget`**: Verifies that +`--target Model::Vehicle` populates `Export.Target`. + +**`Context_Create_ExportCommand_WithFilter_SetsFilterExpression`**: Verifies that +`--filter @Critical` populates `Export.FilterExpression`. + **`Context_Create_ExportCommand_WithFiles_SetsFiles`**: Verifies that a file pattern supplied after the `export` token populates `Export.Files` with the matching glob. diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportArgumentParser.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportArgumentParser.cs index 618582c..accc2c1 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportArgumentParser.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportArgumentParser.cs @@ -27,12 +27,13 @@ namespace DemaConsulting.SysML2Tools.Export; /// 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 +/// Recognizes only --format, --output, --include-stdlib, --target, +/// and --filter, 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. +/// validation style; --target's and --filter's values are likewise captured raw +/// here and resolved/applied later by . /// internal static class ExportArgumentParser { @@ -53,6 +54,8 @@ public static ExportOptions Parse(IReadOnlyList commandArgs) string? format = null; string? output = null; var includeStdlib = false; + string? target = null; + string? filterExpression = null; var files = new List(); var index = 0; @@ -75,6 +78,16 @@ public static ExportOptions Parse(IReadOnlyList commandArgs) includeStdlib = true; break; + case "--target": + target = CliArgumentHelpers.GetRequiredStringArgument( + arg, commandArgs, ref index, "a target qualified-name argument"); + break; + + case "--filter": + filterExpression = CliArgumentHelpers.GetRequiredStringArgument( + arg, commandArgs, ref index, "a filter expression argument"); + break; + default: if (arg.StartsWith("-", StringComparison.Ordinal)) { @@ -92,6 +105,8 @@ public static ExportOptions Parse(IReadOnlyList commandArgs) Format = format, Output = output, IncludeStdlib = includeStdlib, + Target = target, + FilterExpression = filterExpression, Files = files }; } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportCommand.cs index ee22bf4..0114e35 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportCommand.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportCommand.cs @@ -21,6 +21,7 @@ using System.Text; using System.Text.Json; using DemaConsulting.SysML2Tools.Cli; +using DemaConsulting.SysML2Tools.Filtering; using DemaConsulting.SysML2Tools.Io; using DemaConsulting.SysML2Tools.Parser; using DemaConsulting.SysML2Tools.Semantic; @@ -43,6 +44,12 @@ namespace DemaConsulting.SysML2Tools.Export; /// 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. +/// +/// --target/--filter scoping (in that composition order — see +/// 's remarks) are applied on top of the stdlib filtering above, not in +/// place of it: both narrowing steps are independent &&-ed predicates over the +/// same already-stdlib-filtered declarations/edges collections. +/// /// internal static class ExportCommand { @@ -54,6 +61,37 @@ internal static class ExportCommand /// Thrown when is , or when /// --format is not json/jsonl. /// + /// + /// Composition order for --target/--filter (mirroring + /// GeneralViewLayoutStrategy's expose-then-filter pipeline): + /// + /// + /// Stdlib filtering () is applied first, exactly as before this + /// feature existed. + /// + /// + /// --target (when supplied) narrows the stdlib-filtered declarations/edges to + /// the target's containment subtree next. An unresolvable or stdlib-hidden (without + /// --include-stdlib) target is a clean error — no export is produced. + /// + /// + /// --filter (when supplied) narrows the (possibly --target-scoped) + /// declaration set last, using / + /// (both public Core.Filtering types, + /// reused directly — unlike the target-subtree helper below, no duplication is + /// needed here). A parse/evaluation failure does not abort the export: it falls back + /// to the unfiltered (but still target-scoped, if applicable) result, with a synthetic + /// warning appended to + /// (FilePath = "<--filter>", mirroring the [stdlib]… virtual-path + /// convention already used for non-ordinary + /// values) and a matching console warning via . + /// + /// + /// Edges always require both endpoints (when the source is non-null) to survive + /// every active narrowing step — stdlib filtering, --target subtree membership, and + /// --filter matching — consistent with the pre-existing dual-endpoint stdlib-edge + /// behavior (see class remarks); this is not "target-only" edge inclusion. + /// public static async Task RunAsync(Context context) { var options = context.Export @@ -101,7 +139,8 @@ public static async Task RunAsync(Context context) var workspace = loadResult.Workspace; - // Build the filtered declarations/edges, applying the --include-stdlib convention + // Build the stdlib-filtered declarations/edges, applying the --include-stdlib convention, + // exactly as before --target/--filter existed. var declarations = workspace.Declarations .Where(kv => IsVisible(kv.Key, workspace, options.IncludeStdlib)) .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal); @@ -112,12 +151,69 @@ public static async Task RunAsync(Context context) IsVisible(edge.TargetQualifiedName, workspace, options.IncludeStdlib)) .ToList(); - // Diagnostics are never stdlib-filtered — see this class's remarks. + // Diagnostics are never stdlib-filtered — see this class's remarks. Copied to a mutable + // list up front so a --filter parse failure (below) can append a synthetic warning + // diagnostic without mutating loadResult.Diagnostics itself. + var diagnostics = loadResult.Diagnostics.ToList(); + + // Apply --target subtree scoping first (see RunAsync's remarks for the composition order). + if (options.Target is not null) + { + var subjects = ResolveTargetSubtreeSubjects(workspace, options.Target, options.IncludeStdlib); + if (subjects is null) + { + context.WriteError($"export: --target '{options.Target}' was not found in the workspace."); + return; + } + + declarations = declarations + .Where(kv => IsInTargetSubtree(kv.Key, subjects)) + .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal); + + edges = edges + .Where(edge => + (edge.SourceQualifiedName is null || IsInTargetSubtree(edge.SourceQualifiedName, subjects)) && + IsInTargetSubtree(edge.TargetQualifiedName, subjects)) + .ToList(); + } + + // Apply --filter narrowing second, over the (possibly --target-scoped) declaration set. + if (options.FilterExpression is not null) + { + var parseResult = FilterExpressionParser.Parse(options.FilterExpression); + if (parseResult.Expression is { } expression) + { + var evaluation = FilterExpressionEvaluator.Evaluate(workspace, declarations.Keys.ToList(), expression); + var matched = new HashSet(evaluation.MatchedQualifiedNames, StringComparer.Ordinal); + + declarations = declarations + .Where(kv => matched.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal); + + edges = edges + .Where(edge => + (edge.SourceQualifiedName is null || matched.Contains(edge.SourceQualifiedName)) && + matched.Contains(edge.TargetQualifiedName)) + .ToList(); + } + else + { + // Parse failure: fall back to the unfiltered (but still --target-scoped, if + // applicable) result, surfacing the failure via both channels (see remarks). + var reason = parseResult.Diagnostics.FirstOrDefault()?.Message ?? "unknown parse error"; + var message = + $"export: --filter expression '{options.FilterExpression}' failed to parse/evaluate: {reason}; exporting unfiltered."; + + diagnostics.Add(new SysmlDiagnostic("<--filter>", 0, 0, DiagnosticSeverity.Warning, message)); + context.WriteLine($"export: warning: --filter expression '{options.FilterExpression}' failed to parse/evaluate: {reason}; exporting unfiltered."); + } + } + var result = new ExportResult { Declarations = declarations, Edges = edges, - Diagnostics = loadResult.Diagnostics + Diagnostics = diagnostics }; var rendered = format.Equals("jsonl", StringComparison.OrdinalIgnoreCase) @@ -211,6 +307,74 @@ private static string RenderJsonLines(ExportResult result) private static bool IsVisible(string qualifiedName, SysmlWorkspace workspace, bool includeStdlib) => includeStdlib || !workspace.StdlibNames.Contains(qualifiedName); + /// + /// Resolves the containment-subtree "subject" qualified names that --target scopes + /// the export to, or when does not resolve + /// to a visible declaration (either because it is genuinely absent from the workspace, or + /// because it names a standard-library declaration and is + /// — the caller reports a single unified "not found" error for + /// both cases, matching 's existing exclude semantics elsewhere in + /// this file). + /// + /// The loaded workspace, supplying . + /// The raw --target qualified-name text. + /// The --include-stdlib option value. + /// + /// The resolved subject qualified names (the target itself, plus — when the target + /// resolves to a usage/feature — its resolved type's qualified name, so a usage target + /// still yields useful subtree content), or when the target is not + /// a visible declaration. + /// + /// + /// Duplicates (in miniature) the whole-subtree-subject logic of Core's internal + /// DemaConsulting.SysML2Tools.Layout.Internal.ExposeScopeResolver.AddWholeSubtreeSubject + /// (usage-to-resolved-type expansion) — this Tool project cannot reference that + /// Core-internal type, exactly the same duplication rationale already documented on + /// above. Unlike ExposeScopeResolver, export --target + /// only ever has a single target and no per-target bracket filter (the standalone + /// --filter option already covers narrowing), so the multi-target/bracket-filter + /// machinery is deliberately omitted here. + /// + private static List? ResolveTargetSubtreeSubjects(SysmlWorkspace workspace, string target, bool includeStdlib) + { + if (!workspace.Declarations.TryGetValue(target, out var declaration) || + !IsVisible(target, workspace, includeStdlib)) + { + return null; + } + + var subjects = new List { target }; + + // When the target resolves to a usage (e.g. 'part myVehicle : Vehicle;'), its own + // containment subtree is typically empty — the real content lives under its type's + // subtree. Add the resolved type's qualified name too, so a usage target still yields + // useful output instead of a near-empty result. + if (declaration is SysmlFeatureNode feature) + { + var typeTarget = feature.ResolvedEdges + .FirstOrDefault(edge => edge.Kind == SysmlEdgeKind.Typing) + ?.TargetQualifiedName; + if (typeTarget is not null) + { + subjects.Add(typeTarget); + } + } + + return subjects; + } + + /// + /// Returns when is one of + /// or lies within one of their containment subtrees (a + /// "{subject}::" prefix match). + /// + /// The qualified name to test. + /// The resolved --target subtree subjects. + /// when in scope; otherwise . + private static bool IsInTargetSubtree(string qualifiedName, IReadOnlyList subjects) => + subjects.Any(subject => + qualifiedName == subject || qualifiedName.StartsWith(subject + "::", StringComparison.Ordinal)); + /// /// Prints help for the export command. /// @@ -232,5 +396,9 @@ public static void PrintHelp(Context context) context.WriteLine(ExportStrings.Export_OptionOutput1); context.WriteLine(ExportStrings.Export_OptionOutput2); context.WriteLine(ExportStrings.Export_OptionIncludeStdlib); + context.WriteLine(ExportStrings.Export_OptionTarget1); + context.WriteLine(ExportStrings.Export_OptionTarget2); + context.WriteLine(ExportStrings.Export_OptionFilter1); + context.WriteLine(ExportStrings.Export_OptionFilter2); } } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportOptions.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportOptions.cs index d6e236f..dd19117 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportOptions.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportOptions.cs @@ -66,6 +66,40 @@ internal sealed record ExportOptions /// public bool IncludeStdlib { get; init; } + /// + /// Gets the qualified name of the element to restrict the export to, supplied via + /// --target. + /// + /// + /// Captured raw here and resolved/validated later by + /// (which narrows / + /// to the target's containment subtree, reporting a clean error when the name does not + /// resolve to a visible declaration). means no target scoping is + /// applied — the whole (stdlib-filtered) workspace is exported, matching the pre-existing + /// behavior. Applied before — see + /// 's remarks for the composition order. + /// + public string? Target { get; init; } + + /// + /// Gets the Phase 1 filter expression narrowing the exported declarations/edges, supplied + /// via --filter. + /// + /// + /// Mirrors 's style: captured + /// raw text here, passed through unchanged to + /// 's Parse method by + /// — no expression validation happens during parsing + /// of the command line. Unlike render's --filter (which requires + /// --view-type/--view-target), this is independent of : + /// with no , it narrows the whole (stdlib-filtered) workspace; with a + /// , it narrows the target's already-scoped subtree further. A + /// parse/evaluation failure does not abort the export — it falls back to the unfiltered + /// (but still target-scoped, if applicable) result, with a diagnostic and console warning + /// — see 's remarks. + /// + public string? FilterExpression { get; init; } + /// /// Gets the file glob patterns supplied as positional arguments. /// diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.cs b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.cs index cb50ce4..810ef7b 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.cs @@ -61,4 +61,16 @@ internal static class ExportStrings /// Gets the --include-stdlib option line. public static string Export_OptionIncludeStdlib => ResourceManager.GetString(nameof(Export_OptionIncludeStdlib))!; + + /// Gets the first line of the --target option description. + public static string Export_OptionTarget1 => ResourceManager.GetString(nameof(Export_OptionTarget1))!; + + /// Gets the second line of the --target option description. + public static string Export_OptionTarget2 => ResourceManager.GetString(nameof(Export_OptionTarget2))!; + + /// Gets the first line of the --filter option description. + public static string Export_OptionFilter1 => ResourceManager.GetString(nameof(Export_OptionFilter1))!; + + /// Gets the second line of the --filter option description. + public static string Export_OptionFilter2 => ResourceManager.GetString(nameof(Export_OptionFilter2))!; } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.resx index 96c1a00..32c3b1f 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.resx +++ b/src/DemaConsulting.SysML2Tools.Tool/Export/ExportStrings.resx @@ -82,4 +82,16 @@ --include-stdlib Include OMG standard library elements + + --target <qualified-name> Restrict output to the containment subtree rooted at + + + the named element (applied before --filter) + + + --filter <expr> Narrow output using a Phase 1 filter expression + + + (see 'render --filter'); applied after --target + diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs index 30c32d9..8c919f4 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs @@ -689,6 +689,34 @@ public void Context_Create_ExportCommand_WithIncludeStdlibFlag_SetsIncludeStdlib Assert.Equal(0, context.ExitCode); } + /// + /// Test creating a context with export command and --target sets Export.Target. + /// + [Fact] + public void Context_Create_ExportCommand_WithTarget_SetsTarget() + { + // Act: execute the operation being tested + using var context = Context.Create(["export", "--target", "Model::Vehicle"]); + + // Assert: verify expected behavior + Assert.Equal("Model::Vehicle", context.Export!.Target); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with export command and --filter sets Export.FilterExpression. + /// + [Fact] + public void Context_Create_ExportCommand_WithFilter_SetsFilterExpression() + { + // Act: execute the operation being tested + using var context = Context.Create(["export", "--filter", "@Critical"]); + + // Assert: verify expected behavior + Assert.Equal("@Critical", context.Export!.FilterExpression); + Assert.Equal(0, context.ExitCode); + } + /// /// Test creating a context with export command and a file pattern sets Files. /// diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs index db0f650..fca3592 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportRenderingTests.cs @@ -272,6 +272,86 @@ public void ExportIntegration_RealFixture_ProducesValidJsonAndJsonl() } } + /// + /// Full CLI end-to-end integration test: runs the built tool via dotnet against the + /// real VehicleDefinitions.sysml fixture with both --target and + /// --filter supplied together, validating that the composed narrowing produces a + /// smaller, valid JSON document scoped to the target's containment subtree, with every + /// declaration key and edge endpoint confined to that subtree. + /// + [Fact] + public void ExportIntegration_RealFixture_TargetAndFilter_ProducesScopedJson() + { + 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}"); + + // --- Unscoped baseline, for a size comparison below --- + var exitCodeBaseline = Runner.Run(out var baselineOutput, "dotnet", dllPath, "export", "--format", "json", fixtureFile); + Assert.Equal(0, exitCodeBaseline); + + // --- --target + --filter together: --target narrows to the 'Vehicle' subtree; the + // "not @NoSuchMetadataType" --filter expression is a supported Phase 1 construct + // (negated classification test) that matches every candidate (since none carry that + // nonexistent metadata type), so it exercises the composed pipeline without further + // narrowing, keeping the expected result predictable. --- + var exitCodeScoped = Runner.Run( + out var scopedOutput, + "dotnet", dllPath, "export", "--format", "json", + "--target", "VehicleDefinitions::Vehicle", + "--filter", "not @NoSuchMetadataType", + fixtureFile); + Assert.Equal(0, exitCodeScoped); + + var jsonStart = scopedOutput.IndexOf('{'); + Assert.True(jsonStart >= 0, $"Could not find start of JSON document in output:\n{scopedOutput}"); + var scopedDocumentText = scopedOutput[jsonStart..]; + + using var document = JsonDocument.Parse(scopedDocumentText); + var root = document.RootElement; + Assert.True(root.TryGetProperty("Declarations", out var declarations)); + Assert.True(root.TryGetProperty("Edges", out var edges)); + Assert.True(root.TryGetProperty("Diagnostics", out var diagnostics)); + + // No --filter parse failure occurred, so no synthetic warning diagnostic was appended. + Assert.DoesNotContain(diagnostics.EnumerateArray(), d => d.GetProperty("FilePath").GetString() == "<--filter>"); + + // Every declaration key is 'VehicleDefinitions::Vehicle' itself or lies within its + // containment subtree — never an unrelated top-level definition like 'Transmission'. + var declarationNames = declarations.EnumerateObject().Select(p => p.Name).ToList(); + Assert.NotEmpty(declarationNames); + Assert.All(declarationNames, name => + Assert.True( + name == "VehicleDefinitions::Vehicle" || name.StartsWith("VehicleDefinitions::Vehicle::", StringComparison.Ordinal), + $"Declaration '{name}' is outside the --target subtree.")); + + // Every edge's endpoints (when present) also lie within the same subtree. + foreach (var edge in edges.EnumerateArray()) + { + var target = edge.GetProperty("TargetQualifiedName").GetString(); + Assert.NotNull(target); + Assert.True( + target == "VehicleDefinitions::Vehicle" || target!.StartsWith("VehicleDefinitions::Vehicle::", StringComparison.Ordinal), + $"Edge target '{target}' is outside the --target subtree."); + + var source = edge.GetProperty("SourceQualifiedName").GetString(); + if (source is not null) + { + Assert.True( + source == "VehicleDefinitions::Vehicle" || source.StartsWith("VehicleDefinitions::Vehicle::", StringComparison.Ordinal), + $"Edge source '{source}' is outside the --target subtree."); + } + } + + // The scoped, composed export is strictly smaller than the unscoped baseline, confirming + // the narrowing had an observable effect rather than silently exporting everything. + Assert.True(scopedOutput.Length < baselineOutput.Length); + } + /// /// --output pointing at a file inside a directory that doesn't yet exist succeeds, /// creating the missing parent directory (mirroring render's diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportSubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportSubsystemTests.cs index 605cbdb..1af4bed 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportSubsystemTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Export/ExportSubsystemTests.cs @@ -42,6 +42,22 @@ part def Car { } """; + private const string TargetFixture = """ + package Model { + metadata def Critical; + + part def Vehicle { + part engine : Engine; + } + + part def Engine { + @Critical; + } + + part def Accessory; + } + """; + // ---- ExportArgumentParser ---- /// @@ -56,6 +72,8 @@ public void ExportArgumentParser_NoFlags_ProducesDefaults() Assert.Null(options.Format); Assert.Null(options.Output); Assert.False(options.IncludeStdlib); + Assert.Null(options.Target); + Assert.Null(options.FilterExpression); Assert.Equal(["a.sysml", "b.sysml"], options.Files); } @@ -123,6 +141,48 @@ public void ExportArgumentParser_OutputFlagMissingValue_ThrowsArgumentException( Assert.Throws(() => ExportArgumentParser.Parse(["--output"])); } + /// + /// --target captures the qualified-name value. + /// + [Fact] + public void ExportArgumentParser_TargetFlag_CapturesValue() + { + var options = ExportArgumentParser.Parse(["--target", "Model::Vehicle", "a.sysml"]); + + Assert.Equal("Model::Vehicle", options.Target); + Assert.Equal(["a.sysml"], options.Files); + } + + /// + /// --filter captures the raw expression text without validating it (validation happens + /// later in ExportCommand.RunAsync). + /// + [Fact] + public void ExportArgumentParser_FilterFlag_CapturesValue() + { + var options = ExportArgumentParser.Parse(["--filter", "@Critical", "a.sysml"]); + + Assert.Equal("@Critical", options.FilterExpression); + } + + /// + /// --target with no value throws an ArgumentException. + /// + [Fact] + public void ExportArgumentParser_TargetFlagMissingValue_ThrowsArgumentException() + { + Assert.Throws(() => ExportArgumentParser.Parse(["--target"])); + } + + /// + /// --filter with no value throws an ArgumentException. + /// + [Fact] + public void ExportArgumentParser_FilterFlagMissingValue_ThrowsArgumentException() + { + Assert.Throws(() => ExportArgumentParser.Parse(["--filter"])); + } + // ---- ExportCommand happy path ---- /// @@ -229,6 +289,175 @@ public async Task ExportSubsystem_OutputFlag_WritesToFileInsteadOfStdout() } } + // ---- --target / --filter ---- + + /// + /// --target restricts the exported declarations to the target's containment subtree, + /// excluding unrelated top-level declarations. + /// + [Fact] + public async Task ExportSubsystem_TargetFlag_RestrictsToSubtree() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "json", "--target", "Model::Vehicle"); + + Assert.Equal(0, exitCode); + Assert.Contains("Model::Vehicle", output); + Assert.Contains("Model::Vehicle::engine", output); + Assert.DoesNotContain("Model::Accessory", output); + } + + /// + /// --target only includes edges whose source and target both lie within the target's + /// subtree; using a usage (feature) target exercises the usage-to-type expansion, so the + /// usage's own Typing edge to its resolved type survives (both endpoints — the + /// usage and its type — are in scope), while an edge to an unrelated declaration would not. + /// + [Fact] + public async Task ExportSubsystem_TargetFlag_IncludesEdgesWithBothEndpointsInSubtree() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "jsonl", "--target", "Model::Vehicle::engine"); + + Assert.Equal(0, exitCode); + + // The usage's Typing edge (Model::Vehicle::engine -> Model::Engine) survives because + // usage-to-type expansion added Model::Engine to the target's subtree subjects alongside + // the usage itself. + Assert.Contains("Model::Vehicle::engine", output); + Assert.Contains("Model::Engine", output); + Assert.DoesNotContain("Model::Accessory", output); + Assert.Contains("\"kind\":\"edge\"", output.Replace(" ", "", StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase); + } + + /// + /// A --target value that does not resolve to any declaration in the workspace reports a + /// clean "not found" error and produces no export output. + /// + [Fact] + public async Task ExportSubsystem_TargetFlag_UnresolvedName_ReportsNotFoundError() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "json", "--target", "Model::NoSuchElement"); + + Assert.Equal(1, exitCode); + Assert.Contains("--target 'Model::NoSuchElement' was not found in the workspace", output); + } + + /// + /// A --target value naming a standard-library declaration, without --include-stdlib, + /// reports the same "not found" error as a genuinely absent name (an invisible stdlib + /// target is indistinguishable from a nonexistent one). + /// + [Fact] + public async Task ExportSubsystem_TargetFlag_StdlibTargetWithoutIncludeStdlib_ReportsNotFoundError() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "json", "--target", "ScalarValues::Boolean"); + + Assert.Equal(1, exitCode); + Assert.Contains("--target 'ScalarValues::Boolean' was not found in the workspace", output); + } + + /// + /// A --target value naming a standard-library declaration succeeds when --include-stdlib + /// is also supplied, scoping the export to that stdlib element's subtree. + /// + [Fact] + public async Task ExportSubsystem_TargetFlag_StdlibTargetWithIncludeStdlib_Succeeds() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "json", "--target", "ScalarValues::Boolean", "--include-stdlib"); + + Assert.Equal(0, exitCode); + Assert.Contains("ScalarValues::Boolean", output); + Assert.DoesNotContain("Model::Vehicle", output); + } + + /// + /// --filter (with no --target) narrows the exported declarations to those matching the + /// classification-test expression. + /// + [Fact] + public async Task ExportSubsystem_FilterFlag_NarrowsDeclarations() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "json", "--filter", "@Critical"); + + Assert.Equal(0, exitCode); + Assert.Contains("Model::Engine", output); + Assert.DoesNotContain("Model::Vehicle\"", output); + Assert.DoesNotContain("Model::Accessory", output); + } + + /// + /// --filter without --target narrows the whole (stdlib-filtered) workspace, not just a + /// subset scoped by some other means. + /// + [Fact] + public async Task ExportSubsystem_FilterFlag_WithoutTarget_NarrowsWholeWorkspace() + { + var (unfiltered, exitCode1) = await ExportTestFixtures.RunExportAsync(TargetFixture, "--format", "json"); + var (filtered, exitCode2) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "json", "--filter", "@Critical"); + + Assert.Equal(0, exitCode1); + Assert.Equal(0, exitCode2); + Assert.True(filtered.Length < unfiltered.Length); + Assert.Contains("Model::Engine", filtered); + } + + /// + /// --target and --filter compose in order: --target scopes to the subtree first (here, + /// via a usage target whose usage-to-type expansion adds its resolved type to scope), then + /// --filter narrows that already-scoped set further, so only elements satisfying both + /// survive. + /// + [Fact] + public async Task ExportSubsystem_TargetAndFilter_ComposeTargetFirstThenFilter() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "json", "--target", "Model::Vehicle::engine", "--filter", "@Critical"); + + Assert.Equal(0, exitCode); + + // --target's usage-to-type expansion brings Model::Engine into scope alongside the usage + // itself; --filter then narrows to only the elements carrying @Critical: Model::Engine + // matches (it carries the annotation) but the usage itself does not, proving --filter + // narrows the already-scoped set rather than either step alone deciding the result. + Assert.Contains("Model::Engine", output); + Assert.DoesNotContain("Vehicle::engine", output); + Assert.DoesNotContain("Model::Accessory", output); + } + + /// + /// A --filter expression using an unsupported Phase 1 construct does not abort the export: + /// it falls back to the unfiltered result, appends a synthetic warning diagnostic to the + /// output, and prints a matching console warning. + /// + [Fact] + public async Task ExportSubsystem_FilterFlag_UnsupportedConstruct_AddsDiagnosticAndWarns() + { + var (output, exitCode) = await ExportTestFixtures.RunExportAsync( + TargetFixture, "--format", "json", "--filter", "1 + 1"); + + Assert.Equal(0, exitCode); + + // Console warning channel (plain text, not JSON-escaped). + Assert.Contains("export: warning: --filter expression '1 + 1' failed to parse/evaluate", output); + Assert.Contains("Unsupported filter construct", output); + + // Synthetic diagnostic channel: FilePath uses the "<--filter>" virtual-path convention + // (JSON-encoded as \u003C--filter\u003E by System.Text.Json's default HTML-safe encoder) + // with Severity 1 (Warning). + Assert.Contains("\\u003C--filter\\u003E", output); + Assert.Contains("\"Severity\": 1", output); + + // Falls back to the unfiltered result: every declaration is still present. + Assert.Contains("Model::Vehicle", output); + Assert.Contains("Model::Accessory", output); + } + // ---- error paths ---- /// @@ -311,6 +540,8 @@ public async Task ExportSubsystem_ExportHelp_PrintsHelpWithoutThrowing() Assert.Contains("export", outWriter.ToString()); Assert.Contains("--format", outWriter.ToString()); Assert.Contains("--include-stdlib", outWriter.ToString()); + Assert.Contains("--target", outWriter.ToString()); + Assert.Contains("--filter", outWriter.ToString()); Assert.Equal(0, context.ExitCode); } finally