From 48e100cf2e3be5f70e0c5469ca358e42ecd68ee1 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 18 Jul 2026 22:45:45 -0400 Subject: [PATCH] Add query dependencies verb, --walk-depth/--heading flags, and QualifiedNameShortener - Rename the pre-existing --depth flag to --walk-depth for query's impact-walk bounded traversal and render's diagram-nesting depth (breaking CLI change; no CI scripts referenced the old semantics). - Repurpose the pre-existing global --depth flag (shared with --validate) to control query's Markdown heading depth, and add a new --heading override for the auto-generated heading text (Markdown-only; no effect on --format json). - Add a new `query dependencies --element X` verb combining `uses` and `used-by` into one bullet-prose Markdown result (outgoing "Depends on" and incoming "Used by" bullets, each with kind), suitable for pasting into design-doc dependency sections. JSON output gains a new Direction field (Outgoing/Incoming), absent for all other verbs. - Add QualifiedNameShortener, a reusable utility that strips the longest common leading "::"-segment prefix across a pool of qualified names (always keeping each name's leaf), wired into dependencies' Markdown rendering only; JSON stays fully-qualified. - Update design/verification/reqstream docs and CONTRIBUTING/README for all of the above; update CLI/Cli-Context/Query/Render/Utilities help text, tests, and .reviewmark.yaml with a new review-set for the new unit. - Address formal-review findings scoped to this branch's changes: verb-aware query --help schema hints for `dependencies` (was showing an inaccurate generic table/JSON hint), missing --walk-depth no-op note, missing QualifiedNameShortener/Query entries in the Tool decomposition overview, stale CLI contract text, and reqstream traceability gaps for --heading and QualifiedNameShortener. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .reviewmark.yaml | 14 + CONTRIBUTING.md | 2 +- README.md | 22 +- docs/design/introduction.md | 9 + docs/design/sysml2-tools-tool.md | 13 +- docs/design/sysml2-tools-tool/cli/context.md | 48 ++-- docs/design/sysml2-tools-tool/help.md | 4 +- docs/design/sysml2-tools-tool/query.md | 112 ++++++-- docs/design/sysml2-tools-tool/render.md | 14 +- docs/design/sysml2-tools-tool/utilities.md | 42 ++- .../utilities/qualified-name-shortener.md | 55 ++++ docs/reqstream/sysml2-tools-tool/cli.yaml | 11 +- .../sysml2-tools-tool/cli/context.yaml | 16 +- docs/reqstream/sysml2-tools-tool/help.yaml | 8 +- docs/reqstream/sysml2-tools-tool/query.yaml | 78 +++++- docs/reqstream/sysml2-tools-tool/render.yaml | 4 +- .../sysml2-tools-tool/utilities.yaml | 19 +- .../utilities/qualified-name-shortener.yaml | 31 +++ docs/user_guide/introduction.md | 33 ++- .../sysml2-tools-tool/cli/context.md | 23 +- docs/verification/sysml2-tools-tool/help.md | 16 +- docs/verification/sysml2-tools-tool/query.md | 108 +++++++- docs/verification/sysml2-tools-tool/render.md | 6 +- .../sysml2-tools-tool/utilities.md | 14 +- .../utilities/qualified-name-shortener.md | 74 ++++++ requirements.yaml | 1 + .../Cli/Context.cs | 9 +- .../Cli/GlobalArgumentParser.cs | 3 - .../Cli/GlobalArguments.cs | 16 +- .../Program.cs | 1 - .../ProgramStrings.cs | 3 - .../ProgramStrings.resx | 7 +- .../Query/QueryArgumentParser.cs | 32 ++- .../Query/QueryCommand.cs | 33 ++- .../Query/QueryEngine.cs | 37 ++- .../Query/QueryOptions.cs | 20 +- .../Query/QueryResult.cs | 27 +- .../Query/QueryResultRenderer.cs | 112 +++++++- .../Query/QueryStrings.cs | 44 +++- .../Query/QueryStrings.resx | 49 +++- .../Query/QueryVerb.cs | 11 +- .../Render/RenderArgumentParser.cs | 19 +- .../Render/RenderCommand.cs | 5 +- .../Render/RenderCommandOptions.cs | 10 + .../Render/RenderStrings.cs | 7 +- .../Render/RenderStrings.resx | 7 +- .../Utilities/QualifiedNameShortener.cs | 133 ++++++++++ .../Cli/ContextTests.cs | 66 +++-- .../Help/HelpArgumentParserTests.cs | 3 +- .../Help/HelpSubsystemTests.cs | 10 +- .../Query/QueryErrorPathTests.cs | 12 + .../Query/QueryRenderingTests.cs | 242 ++++++++++++++++++ .../Query/QuerySubsystemTests.cs | 36 ++- .../Query/QueryVerbsTests.cs | 98 ++++++- .../Render/RenderSubsystemTests.cs | 8 +- .../Resources/ResxResourceTests.cs | 2 +- .../Utilities/QualifiedNameShortenerTests.cs | 188 ++++++++++++++ 57 files changed, 1737 insertions(+), 290 deletions(-) create mode 100644 docs/design/sysml2-tools-tool/utilities/qualified-name-shortener.md create mode 100644 docs/reqstream/sysml2-tools-tool/utilities/qualified-name-shortener.yaml create mode 100644 docs/verification/sysml2-tools-tool/utilities/qualified-name-shortener.md create mode 100644 src/DemaConsulting.SysML2Tools.Tool/Utilities/QualifiedNameShortener.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tool.Tests/Utilities/QualifiedNameShortenerTests.cs diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 1036c565..1e07a121 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -902,6 +902,20 @@ reviews: - "src/**/Utilities/PathHelpers.cs" - "test/**/Utilities/PathHelpersTests.cs" + - id: SysML2Tools-Tool-Utilities-QualifiedNameShortener + title: Review of SysML2 Tools QualifiedNameShortener unit implementation + context: + - docs/design/sysml2-tools-tool.md + - docs/reqstream/sysml2-tools-tool.yaml + - docs/design/sysml2-tools-tool/utilities.md + - docs/reqstream/sysml2-tools-tool/utilities.yaml + paths: + - "docs/reqstream/sysml2-tools-tool/utilities/qualified-name-shortener.yaml" + - "docs/design/sysml2-tools-tool/utilities/qualified-name-shortener.md" + - "docs/verification/sysml2-tools-tool/utilities/qualified-name-shortener.md" + - "src/**/Utilities/QualifiedNameShortener.cs" + - "test/**/Utilities/QualifiedNameShortenerTests.cs" + - id: SysML2Tools-Tool-Lint title: Review of SysML2 Tools Lint subsystem architecture and interfaces context: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3023539..7d3e819a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -239,7 +239,7 @@ Examples: - `Add support for custom report headers` - `Fix crash when results file path is invalid` -- `Update documentation for --report-depth option` +- `Update documentation for --walk-depth option` - `Refactor argument parsing for better testability` ## Pull Request Process diff --git a/README.md b/README.md index a9776ca3..9810dcc9 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ documentation, CI/CD pipelines, and AI-assisted modeling workflows. - **`lint` Command**: Load a workspace and report all diagnostics; exit non-zero if errors are present — suitable for CI/CD and AI-assisted model-fix loops - **`render` Command**: Load a workspace, resolve a view, and render to SVG or PNG -- **`query` Command**: 11 model-analysis verbs (`uses`, `used-by`, `impact`, `describe`, - `hierarchy`, `requirements`, `interface`, `connections`, `states`, `list`, `find`) for AI +- **`query` Command**: 12 model-analysis verbs (`uses`, `used-by`, `dependencies`, `impact`, + `describe`, `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, @@ -94,7 +95,7 @@ sysml2tools render "src/**/*.sysml" --view SystemContext --output out --format s sysml2tools render model.sysml --auto --output out --format svg # Limit nesting depth (truncated parts show "+N more…") -sysml2tools render model.sysml --output out --depth 3 +sysml2tools render model.sysml --output out --walk-depth 3 # Dynamic (ad-hoc) view: render any resolvable element without declaring a view def in the model sysml2tools render model.sysml --view-type interconnection --view-target Pkg::Engine --output out @@ -123,6 +124,9 @@ sysml2tools query uses --element Pkg::MyPart model.sysml # What depends on this element? (incoming edges) sysml2tools query used-by --element Pkg::MyPart model.sysml +# Dependencies section prose, ready to paste into design docs (combined uses/used-by) +sysml2tools query dependencies --element Pkg::MyPart model.sysml + # Fact sheet: kind, supertypes, typing, annotations, children sysml2tools query describe --element Pkg::MyPart model.sysml @@ -207,7 +211,7 @@ sysml2tools [-v|--version] [-?|-h|--help] [--silent] sysml2tools help [lint|render|query []|export] ``` -`` is `lint`, `render`, `export`, or `query ` (11 query verbs — see the +`` is `lint`, `render`, `export`, or `query ` (12 query verbs — see the *Querying* section above). ### Global Options @@ -240,7 +244,7 @@ sysml2tools help [lint|render|query []|export] | `--view-type ` | Dynamic view kind (`general`/`interconnection`/`state`/`action`/`sequence`/`grid`/`browser`) | | `--view-target ` | Qualified name of the dynamic view's target element; requires `--view-type` | | `--filter ` | Bracket-filter expression narrowing a dynamic view's rendered scope (requires `--view-type`) | -| `--depth <#>` | Limit rendered nesting depth; truncated parts show `+N more…` | +| `--walk-depth <#>` | Limit rendered nesting depth; truncated parts show `+N more…` | `--view-type` and `--view-target` must be specified together, and are mutually exclusive with `--view`/`--auto`; `--filter` is only valid alongside `--view-type`/`--view-target`. @@ -249,15 +253,17 @@ sysml2tools help [lint|render|query []|export] | Option | Description | | --- | --- | -| `` | One of the 11 supported query verbs — see the *Querying* section above | +| `` | One of the 12 supported query verbs — see the *Querying* section above | | `` | One or more glob patterns for `.sysml` input files | | `--element `, `-e ` | Qualified name of the target element; required for every verb except `list`/`find` | | `--format markdown\|json` | Output format (default: `markdown`); distinct from `render`'s `--format` (`svg`/`png`) | -| `--depth <#>` | Maximum impact-walk depth (`impact` verb only); shares the same flag as `render`'s nesting `--depth` | +| `--walk-depth <#>` | Maximum impact-walk depth (`impact` verb only) | | `--direction up\|down\|both` | Traversal direction (`hierarchy` verb only) | | `--kind ` | Element-kind filter (`list`/`find` verbs only) | | `--name ` | Name substring filter (`list`/`find` verbs only) | | `--include-stdlib` | Include OMG standard library elements in results | +| `--depth <#>` | Markdown heading depth (1-6, default: 1); Markdown output only, no effect on `--format json` | +| `--heading ` | Replaces default `# query [: ]`; Markdown only, no effect on JSON | ### `export` Options @@ -275,7 +281,7 @@ sysml2tools help [lint|render|query []|export] | Option | Description | | --- | --- | | `[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 | +| `[verb]` | Optional; only meaningful when `command` is `query` — one of the 12 supported query verbs | ## View Selection diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 7a1479de..a04a6590 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -130,10 +130,19 @@ system, subsystem, and unit levels: `RenderCommand.PrintHelp`, `QueryCommand.PrintGeneralHelp`/`PrintVerbHelp`) - **HelpCommand** (Unit) — parses the optional target command/verb and dispatches to that command's help-printing method + - **Query** (Subsystem) — query command implementation: resolves a workspace, runs one of the + query verbs (`uses`, `used-by`, `dependencies`, `impact`, `describe`, `hierarchy`, + `requirements`, `interface`, `connections`, `states`, `list`, `find`) against it, and renders + the result as a Markdown or JSON `QueryResult` + - **QueryCommand** (Unit) — delegates glob pattern resolution to `GlobFileCollector`, loads + workspace with stdlib seed, dispatches to `QueryEngine` and `QueryResultRenderer` - **SelfTest** (Subsystem) — self-validation test runner - **Validation** (Unit) — self-validation test runner - **Utilities** (Subsystem) — shared utilities - **PathHelpers** (Unit) — safe path combination utilities + - **QualifiedNameShortener** (Unit) — strips the longest common leading `::`-segment prefix + shared across a pool of qualified names, used by the `query dependencies` verb's Markdown + rendering **OTS Dependencies:** diff --git a/docs/design/sysml2-tools-tool.md b/docs/design/sysml2-tools-tool.md index 57fa7d62..d35ac0d8 100644 --- a/docs/design/sysml2-tools-tool.md +++ b/docs/design/sysml2-tools-tool.md @@ -60,10 +60,15 @@ self-testing, and uses `PathHelpers` to construct safe temporary file paths. - *Type*: CLI. - *Role*: Consumer (the host environment invokes the system with command-line arguments). - *Contract*: Accepts arguments `-v`/`--version`, `-?`/`-h`/`--help`, `--silent`, `--validate`, - `--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). Accepts + `--results `, `--result ` (legacy alias for `--results`), `--depth ` (Markdown + heading depth, shared by `--validate` and `query`), `--heading ` (Markdown heading text + override, shared by `query`), and `--log `. Accepts `lint ` as a subcommand + that invokes lint mode. Accepts `render [options] ` (`--walk-depth ` controls + diagram-nesting depth) as a subcommand that renders diagrams. Accepts `help [subcommand]` as a + subcommand (equivalent to `--help`/`-?`/`-h`) that prints general or per-subcommand usage + text. Accepts `query [options] ` (12 verbs; `--element`/`-e`, `--format`, + `--direction`, `--kind`, `--name`, `--include-stdlib`, `--walk-depth` (bounded-traversal depth, + meaningful only for the `impact` verb) 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 diff --git a/docs/design/sysml2-tools-tool/cli/context.md b/docs/design/sysml2-tools-tool/cli/context.md index ad9ef93b..750ee207 100644 --- a/docs/design/sysml2-tools-tool/cli/context.md +++ b/docs/design/sysml2-tools-tool/cli/context.md @@ -19,14 +19,27 @@ grammar, rather than sharing one mega-switch across `lint`/`render`/`query`: command's own grammar, rejecting any flag it does not recognize with an `ArgumentException` naming both the flag and the command. -**Design decision — `--depth` is a global option, not scoped to `render`:** although `--depth` -semantically feeds `render`'s diagram nesting depth and `query`'s `impact`-walk depth, it must -also work with **no command at all** (`sysml2tools --validate --depth 2`, which adjusts -`HeadingDepth` for the self-validation report). Because of this bare-invocation requirement, -`--depth` is parsed once by `GlobalArgumentParser` (feeding `Context.HeadingDepth`/ -`Context.MaxRenderDepth`) and the same raw value is threaded into `QueryOptions.Depth` by -`Context.Create` when dispatching to `QueryArgumentParser`. This is a deliberate exception to the -general per-command-scoping principle, justified by the pre-existing bare-`--depth` behavior. +**Design decision — walk/nesting depth is per-command-scoped:** `render`'s diagram nesting +depth and `query`'s `impact`-walk depth are both parsed locally, as `--walk-depth`, by +`RenderArgumentParser`/`QueryArgumentParser` respectively — an ordinary command-scoped flag +like any other, with no special-cased global plumbing. The global `--depth` flag is a +separate, unrelated concept: Markdown heading depth, feeding `Context.HeadingDepth`. It must +work with **no command at all** (`sysml2tools --validate --depth 2`, which adjusts +`HeadingDepth` for the self-validation report), and `query` also reads it directly +(`Context.HeadingDepth`) for its own Markdown report heading depth — this is the only +cross-command reuse of `--depth`, and it is a direct read of the already-parsed global value, +not a second parse. + +**Design decision — `--heading` is intentionally query-scoped, not global:** unlike `--depth`, +the `--heading ` override (custom Markdown heading text) is parsed entirely within +`QueryArgumentParser` and stored on `QueryOptions.Heading`; it is **not** a `Context`/ +`GlobalArgumentParser` concept and has no effect on `--validate`. This is deliberate: `--depth` +needed global scope because `--validate`'s self-validation report predates `query` and already +used the shared `Context.HeadingDepth` flag, but a custom heading *title* is only meaningful for +`query`'s report-style output (e.g. pasting a `query dependencies` result into a design doc under +a caller-chosen heading) — `--validate`'s self-validation summary has no equivalent "custom title" +use case, so promoting `--heading` into the shared global layer would add unused surface area to +`lint`/`render`/`--validate` for no current benefit. #### Data Model @@ -47,12 +60,9 @@ to `false` within the same invocation. neither flag was present. **HeadingDepth**: `int` — Heading depth for markdown output; valid range 1–6, default 1; -supplied via `--depth`. Parsed by `GlobalArgumentParser` (see design decision above). - -**MaxRenderDepth**: `int?` — Raw diagram render depth supplied via `--depth`; not clamped -to 6. `null` when `--depth` was not specified. Used by the render command as the -`DepthLimit` in `RenderOptions`, and threaded into `Query.Depth` for the `query` command's -`impact` verb; 0 is interpreted as unlimited. +supplied via `--depth`. Parsed by `GlobalArgumentParser` (see design decision above); read +directly by `SelfTest/Validation.cs` (self-validation report) and `QueryCommand.RunAsync` +(query's Markdown report heading depth). **Command**: `SysmlCommand` — `SysmlCommand.Lint` when `lint` is the first recognized command token; `SysmlCommand.Render` when `render` is the first recognized command token; @@ -65,7 +75,8 @@ its own file, `Cli/SysmlCommand.cs`. **Render**: `RenderCommandOptions?` — populated only when `Command` is `SysmlCommand.Render`. Carries `OutputDirectory`, `Format` (`"svg"`/`"png"`, validated by `RenderCommand.RunAsync` — not -at parse time), `ViewName`, `AutoView`, and `Files`. Named `RenderCommandOptions` rather than +at parse time), `ViewName`, `AutoView`, `WalkDepth` (diagram nesting depth limit, command-scoped), +and `Files`. Named `RenderCommandOptions` rather than `RenderOptions` to avoid colliding with `DemaConsulting.Rendering.Abstractions.RenderOptions`, the off-the-shelf rendering-library type already used inside `RenderCommand`. @@ -74,7 +85,8 @@ recognized verb token was captured; `null` when `query` was supplied without a v `--help` was requested (e.g., `query --help`), or when a different command was selected. Carries `--element`/`-e`, `--direction`, `--kind`, `--name`, `--include-stdlib`, the query's own `--format` (`"markdown"`/`"json"`, a value independent of `render`'s `--format` even though the -flag name is shared), the `Depth` value threaded from the global `--depth`, and the +flag name is shared), `WalkDepth` (impact-walk depth, command-scoped, parsed from `query`'s own +`--walk-depth`), `Heading` (custom Markdown heading text from `--heading`), and the query-specific `Files` list. **HelpCommand**: `HelpOptions?` — populated only when `Command` is `SysmlCommand.Help`. Named @@ -100,7 +112,7 @@ switches on `GlobalArguments.Command` to dispatch to exactly one per-command par - `SysmlCommand.Lint` → `LintArgumentParser.Parse(global.CommandArgs)` → `Lint`. - `SysmlCommand.Render` → `RenderArgumentParser.Parse(global.CommandArgs)` → `Render`. -- `SysmlCommand.Query` → `QueryArgumentParser.Parse(global.CommandArgs, global.Help, global.MaxRenderDepth)` +- `SysmlCommand.Query` → `QueryArgumentParser.Parse(global.CommandArgs, global.Help)` → `Query`. The `query` grammar is **structural**: the first token after the `query` command token must be a recognized verb (validated eagerly via `QueryVerbParsing.Parse`, which lists all valid tokens on failure); when no verb token is present, parsing returns `null` if `--help` was @@ -176,7 +188,7 @@ available. - **Program** — creates `Context` via `Context.Create` and calls `WriteLine` and `WriteError`. - **Validation** — receives `Context` from `Program` and calls `WriteLine` and `WriteError`. - **LintCommand** — reads `Lint` (`LintOptions`); calls `WriteLine` and `WriteError`. -- **RenderCommand** — reads `Render` (`RenderCommandOptions`) and `MaxRenderDepth`; calls +- **RenderCommand** — reads `Render` (`RenderCommandOptions`); calls `WriteLine` and `WriteError`. - **QueryCommand** — reads `Query` (`QueryOptions`); calls `WriteLine` and `WriteError`. - **HelpCommand** — reads `HelpCommand` (`HelpOptions`); dispatches to diff --git a/docs/design/sysml2-tools-tool/help.md b/docs/design/sysml2-tools-tool/help.md index 046bb8e0..dc97420b 100644 --- a/docs/design/sysml2-tools-tool/help.md +++ b/docs/design/sysml2-tools-tool/help.md @@ -12,7 +12,7 @@ It provides three cooperating types: - `HelpArgumentParser` — parses the arguments following the `help` command token: an optional first token naming the target command, followed — only when the target is `query` — by an optional second token naming the verb, re-validated via `QueryVerbParsing.Parse` rather than - duplicating the 11-verb vocabulary. + duplicating the 12-verb vocabulary. - `HelpCommand` — pure dispatch. `Run(Context)` never authors help text itself; it delegates to the single source of truth for each command's help text: `Program.PrintTopLevelHelp`, `LintCommand.PrintHelp`, `RenderCommand.PrintHelp`, or @@ -102,7 +102,7 @@ help text. Authors no help text of its own. - Unrecognized target command (`help bogus`): `ArgumentException` (thrown by `HelpArgumentParser.Parse`) listing the three valid targets. - Unrecognized query verb (`help query bogus`): `ArgumentException` (thrown by - `QueryVerbParsing.Parse`, reused as-is) listing all 11 valid verb tokens. + `QueryVerbParsing.Parse`, reused as-is) listing all 12 valid verb tokens. - Extra/`-`-prefixed trailing token: `ArgumentException` naming the bad token and the `help` command. - All three cases propagate to `Program.Main`'s existing `ArgumentException` handler, which diff --git a/docs/design/sysml2-tools-tool/query.md b/docs/design/sysml2-tools-tool/query.md index 823e8910..3c71cedc 100644 --- a/docs/design/sysml2-tools-tool/query.md +++ b/docs/design/sysml2-tools-tool/query.md @@ -3,16 +3,17 @@ #### Overview The Query subsystem implements the `query` CLI command: a model-analysis interface exposing -11 verbs (`uses`, `used-by`, `impact`, `describe`, `hierarchy`, `requirements`, `interface`, -`connections`, `states`, `list`, `find`) over a SysML v2 workspace. It provides five +12 verbs (`uses`, `used-by`, `dependencies`, `impact`, `describe`, `hierarchy`, +`requirements`, `interface`, `connections`, `states`, `list`, `find`) over a SysML v2 +workspace. It provides five cooperating types: -- `QueryVerb` — an enum identifying which of the 11 operations was requested, plus a +- `QueryVerb` — an enum identifying which of the 12 operations was requested, plus a `QueryVerbParsing` helper that converts between kebab-case command-line tokens (e.g., `used-by`) and enum values, and reports which verbs require a target element. - `QueryOptions` — an immutable record capturing every verb-specific option (`Element`, - `Format`, `Depth`, `Direction`, `Kind`, `NameFilter`, `IncludeStdlib`, `Files`) parsed by - `Context.Create` for one `query` invocation. + `Format`, `WalkDepth`, `Direction`, `Kind`, `NameFilter`, `IncludeStdlib`, `Heading`, + `Files`) parsed by `Context.Create` for one `query` invocation. - `QueryCommand` — the entry-point dispatcher, mirroring `LintCommand`/`RenderCommand`'s `internal static class` shape with a `RunAsync(Context)` method, plus `PrintGeneralHelp` and `PrintVerbHelp` for `--help` rendering. Loads the workspace, resolves the target @@ -35,7 +36,8 @@ re-resolving anything; `QueryEngine` is a pure read-only consumer of the workspa | --- | --- | --- | | `uses` | yes | `Index.GetOutgoingEdges(qn)` | | `used-by` | yes | `Index.GetIncomingEdges(qn)` | -| `impact` | yes | Breadth-first transitive closure over `used-by`, cycle-guarded, bounded by `--depth` | +| `dependencies` | yes | Merges `QueryEngine.Uses` + `QueryEngine.UsedBy` (no separate traversal) | +| `impact` | yes | Breadth-first transitive closure over `used-by`, cycle-guarded, bounded by `--walk-depth` | | `describe` | yes | Node's own kind, resolved supertypes, typing, annotations, children | | `hierarchy` | yes | Recursive `Supertype` edge walk, direction-controlled, cycle-guarded | | `requirements` | yes | `Satisfy`/`Verify`/`Allocate` edges where the element is source or target | @@ -48,6 +50,9 @@ re-resolving anything; `QueryEngine` is a pure read-only consumer of the workspa Entry shapes, one row per verb: - `uses`/`used-by`: other-side qn, `Kind` = edge kind label, `Detail` = other side's kind. +- `dependencies`: the merged `uses` (outgoing) + `used-by` (incoming) entries, each tagged + with `Direction` (`Outgoing`/`Incoming`); no `Summary` lines (the prose rendering supplies + its own intro sentences instead — see **Output Model** below). - `impact`: qn of every element transitively affected by a change to the target. - `describe`: direct `Children` as entries (child qn, `Kind` = child's kind); the node's own kind/supertypes/typing/annotations/child-count appear in `Summary`, not `Entries`. @@ -91,9 +96,9 @@ once, downstream, in `QueryResultRenderer` (see **Output Model**). ##### QueryResult / QueryResultEntry Purpose -A single, uniform result shape used by all 11 verbs so that `QueryResultRenderer` never -needs verb-specific rendering logic, and so Markdown/JSON output are always structurally -identical. +A single, uniform result shape used by all 12 verbs so that `QueryResultRenderer` never +needs verb-specific rendering logic (except the one `dependencies`-only branch documented +below), and so Markdown/JSON output are always structurally identical. ##### QueryResult / QueryResultEntry Data Model @@ -102,7 +107,13 @@ identical. (`IReadOnlyList`). - `QueryResultEntry`: `QualifiedName`, `Kind`, `Detail` (`string?`), `Notes` (`IReadOnlyList`, currently unused by any verb but reserved for future - multi-line annotations). + multi-line annotations), `Direction` (`QueryEntryDirection?`, `[JsonIgnore(Condition = + JsonIgnoreCondition.WhenWritingNull)]`) — populated only by `dependencies` + (`Outgoing`/`Incoming`); `null` for every other verb, and omitted from JSON output + entirely when `null` (rather than serialized as `"Direction": null`), so adding this + field does not change any other verb's JSON output shape. +- `QueryEntryDirection`: a plain enum, `Outgoing`/`Incoming`, defined alongside + `QueryResult`/`QueryResultEntry`. ##### QueryResultRenderer Purpose @@ -112,10 +123,49 @@ output. ##### QueryResultRenderer Key Methods -**`RenderMarkdown(QueryResult)`**: Returns `IReadOnlyList` lines — an `# query -[: ]` heading, the `Summary` lines as a bullet list, then either `_No -entries._` or a Markdown table (`| Qualified Name | Kind | Detail |`) of `SortEntries`' -output. +**`RenderMarkdown(QueryResult, int depth = 1, string? heading = null)`**: +Returns `IReadOnlyList` lines — a `new string('#', depth)`-prefixed heading +(default one `#`) whose text is either `heading` (when supplied, verbatim, with no +merging of verb/element information) or the auto-generated `query [: ]` text, +followed by the `Summary` lines as a bullet list, then either `_No entries._` or a Markdown +table (`| Qualified Name | Kind | Detail |`) of `SortEntries`' output. `depth` is sourced from +the global `--depth` flag (`Context.HeadingDepth`, shared with the `--validate` self-report +heading; pre-validated to the range 1-6 by `GlobalArgumentParser`); `heading` is sourced from +`query`'s own `--heading` flag (parsed by `QueryArgumentParser`). Both are Markdown output +only; no effect on `RenderJson`. + +**`dependencies`-only rendering path**: after the heading (and empty `Summary`), when +`result.Verb == "dependencies"`, `RenderMarkdown` first builds the combined pool +`[Element] + sortedEntries.Select(QualifiedName)` and calls +`Utilities.QualifiedNameShortener.Shorten` on it, producing a map from each original qualified +name to its shortened form (see `docs/design/sysml2-tools-tool/utilities/qualified-name-shortener.md` +for the stripping algorithm). It then branches to a private +`RenderDependenciesBody(sortedEntries, element, shortened)` helper instead of the table logic +above — the only per-verb branch in an otherwise verb-agnostic method. It splits the (already +`SortEntries`-sorted) entries by `Direction`, preserving each group's ordinal order, and emits +(using the shortened form of `Element` and every entry's `QualifiedName`): + +```text +{Element} references the following elements: + +- Depends on **{QualifiedName}** ({Kind}) +... + +The following elements reference {Element}: + +- Used by **{QualifiedName}** ({Kind}) +... +``` + +When the outgoing group is empty, the intro sentence and bullet list are replaced by a +single `"{Element} has no outgoing references."` line; when the incoming group is empty, +by a single `"No elements reference {Element}."` line. No table, and no `"_No entries._"` +fallback, are ever emitted for `dependencies`. `RenderJson` needs no special-casing for +`dependencies`: the `Direction` field already round-trips through the existing +`QueryResultSerializerContext` serialization once the property exists on +`QueryResultEntry`, and `RenderJson` never calls `QualifiedNameShortener` — its +`QualifiedName`/`Element` values remain fully qualified for every verb, including +`dependencies`. **`RenderJson(QueryResult)`**: Returns an indented JSON string via `JsonSerializer.Serialize(sortedResult, QueryResultSerializerContext.Default.QueryResult)`, @@ -135,7 +185,7 @@ the Tool assembly: `[JsonSerializable(typeof(QueryResult))]` with ##### QueryVerb Purpose -Defines the fixed, ROADMAP-specified vocabulary of 11 query verbs and centralizes the +Defines the fixed, ROADMAP-specified vocabulary of 12 query verbs and centralizes the mapping between command-line tokens and the enum, so no other type re-implements token parsing or formatting. @@ -160,9 +210,9 @@ text). ##### QueryOptions Purpose -A single, flat, immutable record carrying every option relevant to any of the 11 verbs. +A single, flat, immutable record carrying every option relevant to any of the 12 verbs. A shared shape (rather than one record per verb) keeps `Context.Create`'s construction -logic simple, since all 11 verbs share the same CLI grammar (verb token, then options, +logic simple, since all 12 verbs share the same CLI grammar (verb token, then options, then file globs) and differ only in which options are meaningful. ##### QueryOptions Data Model @@ -173,14 +223,20 @@ then file globs) and differ only in which options are meaningful. - `Format` (`string?`) — `"markdown"` (default) or `"json"` from `--format`; reuses the same flag name as `render`'s `--format` (which instead accepts `svg`/`png`) — the two commands interpret the raw string independently. -- `Depth` (`int?`) — impact-walk depth bound from `--depth`, meaningful only for `impact`; - reuses the same flag as `render`'s diagram-nesting `--depth`. +- `WalkDepth` (`int?`) — impact-walk depth bound from `--walk-depth`, meaningful only for + `impact`; command-scoped (parsed by `QueryArgumentParser`, unrelated to `render`'s own + `--walk-depth` flag). - `Direction` (`string?`) — `up`/`down`/`both` from `--direction`, meaningful only for `hierarchy`. - `Kind` (`string?`) — element-kind filter from `--kind`, meaningful only for `list`/`find`. - `NameFilter` (`string?`) — name substring filter from `--name`, meaningful only for `list`/`find`. - `IncludeStdlib` (`bool`) — from `--include-stdlib`; applies to every verb. +- `Heading` (`string?`) — custom Markdown heading text from `--heading`, replacing the + auto-generated `query [: ]` text; `null` means the auto-generated text is + used. Markdown output only; no effect on `--format json`. The Markdown heading *depth* + (number of leading `#` characters) is a separate concept, read directly from the global + `Context.HeadingDepth` (`--depth`) by `QueryCommand.RunAsync`, not stored on `QueryOptions`. - `Files` (`IReadOnlyList`) — file glob patterns supplied after the verb token; kept separate from `Context.Lint`/`Context.Render`'s file lists (used by `lint`/`render`) so query's file handling cannot affect the other commands. @@ -221,22 +277,22 @@ exposes `PrintGeneralHelp`/`PrintVerbHelp` for `Program`'s help handling. 8. For verbs requiring an element, looks up `workspace.Declarations.TryGetValue(element, out node)`; `WriteError`s `"query {token}: element '{element}' not found in the workspace."` and returns (exit code 1) when missing. -9. Dispatches via an explicit 11-arm `switch` on `options.Verb` — one arm per verb, not a +9. Dispatches via an explicit 12-arm `switch` on `options.Verb` — one arm per verb, not a loop or dictionary — to the matching `QueryEngine` method. 10. Renders the resulting `QueryResult` via `QueryResultRenderer.RenderMarkdown`/`RenderJson` and writes each line/the JSON string via `context.WriteLine`. -**`PrintGeneralHelp(Context context)`**: Lists all 11 verbs with a one-line description and +**`PrintGeneralHelp(Context context)`**: Lists all 12 verbs with a one-line description and the shared option set; used for `query --help` with no verb. Also prints a "typical workflow" note recommending `list`/`find` first to discover exact qualified names before -using an element-scoped verb, since 9 of the 11 verbs require `--element`. +using an element-scoped verb, since 10 of the 12 verbs require `--element`. **`PrintVerbHelp(Context context, QueryVerb verb)`**: Prints a verb-specific usage line and only the options relevant to that verb, followed by one real example invocation (drawn from the bundled `test/SysMLModels/OMG/examples/VehicleExample/*.sysml` fixture, or explicitly marked "illustrative" for the two verbs — `requirements`, `states` — that fixture has no matching content for) and the shared Markdown/JSON output-shape schema hint (identical for -every verb, since all 11 share one `QueryResult`/`QueryResultRenderer`); used for +every verb, since all 12 share one `QueryResult`/`QueryResultRenderer`); used for `query --help`. #### Localization / Resource Strings @@ -246,12 +302,12 @@ the "typical workflow" note, the per-verb example invocations, and the schema hi sourced from `QueryStrings`, a hand-written, culture-aware `ResourceManager` accessor over `Query/QueryStrings.resx` — see `docs/design/sysml2-tools-tool/program.md`'s "Localization / Resource Strings" section for the rationale and the zero-code-change future-locale story, -which applies identically here. The 11 per-verb example-invocation keys +which applies identically here. The 12 per-verb example-invocation keys (`Query_Example_Uses` … `Query_Example_Find`) are each backed by their own `public static string` property (so the resx-key/accessor-property parity check in `ResxResourceTests` needs no special-casing), and are additionally exposed through a single `QueryStrings.GetExample(QueryVerb)` switch-expression helper so `PrintVerbHelp` only needs -one call site instead of an 11-arm switch of its own. +one call site instead of a 12-arm switch of its own. #### Error Handling @@ -277,6 +333,9 @@ one call site instead of an 11-arm switch of its own. - `WorkspaceLoader`, `StdlibProvider`, `SysmlWorkspace`, `SemanticIndex`, `SysmlNode` and derived types (in `DemaConsulting.SysML2Tools.Semantic`/`.Internal`) — workspace loading and the model read by `QueryEngine`. +- `Utilities.QualifiedNameShortener` (in `DemaConsulting.SysML2Tools.Utilities`) — + `QueryResultRenderer.RenderMarkdown`'s `dependencies`-only branch calls `Shorten` on the + combined subject-plus-entries pool before rendering. #### Callers @@ -298,6 +357,8 @@ one call site instead of an 11-arm switch of its own. | SysML2Tools-Tool-Query-Help | `QueryCommand.PrintGeneralHelp`/`PrintVerbHelp`, called from `Program.RunAsync` | | SysML2Tools-Tool-Query-Uses | `QueryEngine.Uses` | | SysML2Tools-Tool-Query-UsedBy | `QueryEngine.UsedBy` | +| SysML2Tools-Tool-Query-Dependencies | `QueryEngine.Dependencies`, `QueryResultRenderer`'s `dependencies` branch | +| SysML2Tools-Tool-Query-DependenciesNameShortening | `Utilities.QualifiedNameShortener.Shorten` | | SysML2Tools-Tool-Query-Impact | `QueryEngine.Impact` | | SysML2Tools-Tool-Query-Describe | `QueryEngine.Describe` | | SysML2Tools-Tool-Query-Hierarchy | `QueryEngine.Hierarchy` | @@ -312,3 +373,4 @@ one call site instead of an 11-arm switch of its own. | SysML2Tools-Tool-Query-OutputFormat | `QueryResultRenderer.RenderMarkdown`/`RenderJson`/`SortEntries` | | SysML2Tools-Tool-Query-LocalizableHelpText | `QueryStrings` accessor, used by `PrintGeneralHelp`/`PrintVerbHelp` | | SysML2Tools-Tool-Query-HelpEnrichment | `QueryStrings.GetExample`/`SchemaHint_*`, used by `PrintVerbHelp` | +| SysML2Tools-Tool-Query-ReportHeading | `QueryOptions.Heading`, `Cli.Context.HeadingDepth`, `RenderMarkdown` | diff --git a/docs/design/sysml2-tools-tool/render.md b/docs/design/sysml2-tools-tool/render.md index bcb5510c..2453983d 100644 --- a/docs/design/sysml2-tools-tool/render.md +++ b/docs/design/sysml2-tools-tool/render.md @@ -23,9 +23,9 @@ No instance state. All data flows through the `Context` parameter and local vari - Input: `Context.Render` (a `RenderCommandOptions` populated by `RenderArgumentParser`) — file patterns (`Files`), format (`Format`), output path (`OutputDirectory`), view filter (`ViewName`), auto-view flag (`AutoView`), dynamic-view kind (`ViewType`), dynamic-view target - (`ViewTarget`), dynamic-view filter expression (`FilterExpression`); plus - `Context.MaxRenderDepth` (render depth, parsed globally — see - `docs/design/sysml2-tools-tool/cli/context.md`) + (`ViewTarget`), dynamic-view filter expression (`FilterExpression`), and maximum diagram + nesting depth (`WalkDepth`, command-scoped — parsed by `RenderArgumentParser`, unrelated to + the global `--depth` flag; see `docs/design/sysml2-tools-tool/cli/context.md`) - Intermediate: `SysmlLoadResult` — workspace and diagnostics from `WorkspaceLoader` - Output: files written to `OutputDirectory` via `File.Create` @@ -84,7 +84,7 @@ Entry point for the render command. Steps: throws only once the command actually runs. Selects `PngRenderer` when `format` equals `"png"`; `SvgRenderer` otherwise. 9. Calls `DiagramRenderer.RenderWorkspace` passing - `new RenderOptions(Themes.Light, DepthLimit: context.MaxRenderDepth ?? 0)` and + `new RenderOptions(Themes.Light, DepthLimit: options.WalkDepth ?? 0)` and `viewFilter: effectiveViewFilter` (the synthesized dynamic view's name when one was synthesized in step 5; `options.ViewName` unchanged otherwise). 10. Writes a "No views found" message and returns when `outputs` is empty. @@ -92,9 +92,9 @@ Entry point for the render command. Steps: it via `Directory.CreateDirectory`, and writes each `RenderOutput.Data` stream to a file named `RenderOutput.SuggestedFileName`. -**`PrintHelp(Context context)`**: Prints `render`'s usage line and its seven flags (`--output`, -`--format`, `--view`, `--auto`, `--view-type`, `--view-target`, `--filter`), plus a note about -the shared global `--depth` option. This is the single source of truth for both `render --help` +**`PrintHelp(Context context)`**: Prints `render`'s usage line and its eight flags (`--output`, +`--format`, `--view`, `--auto`, `--view-type`, `--view-target`, `--filter`, `--walk-depth`). +This is the single source of truth for both `render --help` (dispatched from `Program.RunAsync`'s command-aware help block) and `help render` (dispatched from `Help.HelpCommand.Run`); neither entry point duplicates the help text. Every line printed by `PrintHelp` is sourced from `RenderStrings`, a hand-written, culture-aware `ResourceManager` diff --git a/docs/design/sysml2-tools-tool/utilities.md b/docs/design/sysml2-tools-tool/utilities.md index 86d0729c..f4b89b84 100644 --- a/docs/design/sysml2-tools-tool/utilities.md +++ b/docs/design/sysml2-tools-tool/utilities.md @@ -3,10 +3,11 @@ ### Overview The `Utilities` subsystem provides shared utility functions for the SysML2 Tools. It -supplies reusable, independently testable helpers consumed by other subsystems. Its primary -responsibility is safe file-path manipulation, protecting callers from path-traversal -vulnerabilities when constructing paths from caller-supplied inputs. The `Utilities` subsystem -contains one unit: `PathHelpers`. +supplies reusable, independently testable helpers consumed by other subsystems. Its +responsibilities are safe file-path manipulation (protecting callers from path-traversal +vulnerabilities when constructing paths from caller-supplied inputs) and qualified-name +compaction for Markdown-oriented rendering. The `Utilities` subsystem contains two units: +`PathHelpers` and `QualifiedNameShortener`. ### Interfaces @@ -22,13 +23,30 @@ that escapes the base directory. when the combined path escapes the base directory; may propagate `NotSupportedException` or `PathTooLongException` from underlying BCL path operations. -### Design +**QualifiedNameShortener.Shorten**: Strips the longest shared leading `::`-segment prefix from a +pool of qualified names, always retaining each name's own leaf segment. -The `Utilities` subsystem contains only the `PathHelpers` unit. It has no dependencies on other -tool units or subsystems; it uses only .NET BCL types (`Path`, `ArgumentNullException`). +- *Type*: In-process .NET static method. +- *Role*: Provider. +- *Contract*: Accepts `IReadOnlyList qualifiedNames`. Returns an + `IReadOnlyDictionary` mapping each distinct original name to its shortened + form, stripping the longest run of leading segments common to every name in the pool, capped + so every name always keeps at least its own final segment. +- *Constraints*: Throws `ArgumentNullException` when the pool or any contained name is `null`. + Returns an identity map (no stripping) when the pool has fewer than 2 distinct names, or when + the names share no common leading segment. + +### Design -`PathHelpers.SafePathCombine` is a pure utility method: it performs no file-system I/O, holds -no state, and throws immediately on invalid input. All calls to `SafePathCombine` in the -codebase originate from the `SelfTest` subsystem (`Validation`), which uses it to construct -log and result file paths inside temporary directories created during self-validation test -execution. +The `Utilities` subsystem contains the `PathHelpers` and `QualifiedNameShortener` units. Neither +unit depends on the other, and neither depends on any other tool unit or subsystem; both use +only .NET BCL types (`Path`/`ArgumentNullException` for `PathHelpers`; `string.Split`/ +`string.Join`/`ArgumentNullException` for `QualifiedNameShortener`). + +`PathHelpers.SafePathCombine` and `QualifiedNameShortener.Shorten` are both pure utility +methods: they perform no file-system I/O, hold no state, and throw immediately on invalid +input. All calls to `SafePathCombine` in the codebase originate from the `SelfTest` subsystem +(`Validation`), which uses it to construct log and result file paths inside temporary +directories created during self-validation test execution. The sole caller of +`QualifiedNameShortener.Shorten` is the `Query` subsystem's `QueryResultRenderer`, which applies +it only to the `dependencies` verb's Markdown-only prose rendering. diff --git a/docs/design/sysml2-tools-tool/utilities/qualified-name-shortener.md b/docs/design/sysml2-tools-tool/utilities/qualified-name-shortener.md new file mode 100644 index 00000000..5fe36a82 --- /dev/null +++ b/docs/design/sysml2-tools-tool/utilities/qualified-name-shortener.md @@ -0,0 +1,55 @@ +### QualifiedNameShortener + +#### Purpose + +`QualifiedNameShortener` is a static utility class that strips the longest leading `::`-segment +prefix shared by every name in a supplied pool of qualified names. Its single responsibility is +to make a set of related qualified names more compact for Markdown-oriented display, without +ever discarding a name's own distinguishing leaf segment. + +#### Data Model + +`QualifiedNameShortener` holds no instance state. The class is `internal static` with no fields +or properties other than the private `SegmentSeparator` constant (`"::"`). + +#### Key Methods + +**Shorten**: Computes a shortened form of every supplied qualified name. + +- *Parameters*: `IReadOnlyList qualifiedNames` — the pool of qualified names to shorten + together. +- *Returns*: `IReadOnlyDictionary` — a map from each distinct original name + (ordinal key comparison) to its shortened form. +- *Preconditions*: `qualifiedNames` and every contained name are non-null. +- *Postconditions*: Every name in the pool keeps at least its own final ("::"-delimited) leaf + segment; when fewer than 2 distinct names are supplied, or the names share no common leading + segment, every value equals its key unchanged. + +Algorithm: (1) reject a null pool or a null entry via `ArgumentNullException.ThrowIfNull`; (2) +reduce the pool to its distinct names (`Distinct(StringComparer.Ordinal)`); (3) if fewer than 2 +distinct names remain, return an identity map (skip stripping entirely — there is nothing to +compare a lone name against); (4) split every distinct name into "::"-segments; (5) compute the +common-prefix length via the private `ComputeCommonPrefixLength` helper, which caps the search at +the shortest name's segment count minus 1 (so no name can ever be stripped down to nothing) and +walks segment indices until the first mismatch (ordinal comparison) across every name; (6) when +the computed length is 0, return an identity map; otherwise return a map of each distinct name to +`string.Join("::", segments[commonPrefixLength..])`. + +#### Error Handling + +`Shorten` throws `ArgumentNullException` when `qualifiedNames` itself is `null`, or when any +contained name is `null`. No other exception is thrown; a pool sharing no common prefix, or +containing only one distinct name, is a normal (non-error) input handled by returning an +identity map. + +#### Dependencies + +- **.NET BCL** — `string.Split`, `string.Join`, `Enumerable.Distinct`/`Min`, and + `ArgumentNullException` are the only dependencies. No other tool units or subsystems are used. + +#### Callers + +- **Query** — `QueryResultRenderer.RenderMarkdown`'s `dependencies`-only rendering branch calls + `QualifiedNameShortener.Shorten` on the pool `[Element] + Entries.Select(QualifiedName)` before + rendering the subject sentence and bullets, so Markdown output for a related set of names is + more compact. No other verb, and no JSON output, calls this utility. diff --git a/docs/reqstream/sysml2-tools-tool/cli.yaml b/docs/reqstream/sysml2-tools-tool/cli.yaml index 60a588a2..bc0af365 100644 --- a/docs/reqstream/sysml2-tools-tool/cli.yaml +++ b/docs/reqstream/sysml2-tools-tool/cli.yaml @@ -79,11 +79,14 @@ sections: - CliSubsystem_ResultsFlow_ContextAndProgram_WritesResultsFile - id: Template-Cli-Depth - title: The Cli subsystem shall support --depth flag to control the heading depth of markdown output. + title: >- + The Cli subsystem shall support --depth flag to control the heading depth of markdown + output, shared by both --validate self-validation output and the query command's + Markdown rendering. justification: | - Enables validation output to be embedded within larger markdown documents at - the appropriate heading level, supporting documentation workflows that combine - multiple reports. + Enables validation output and query results to be embedded within larger markdown + documents at the appropriate heading level, supporting documentation workflows that + combine multiple reports. tests: - CliSubsystem_DepthFlow_ContextAndProgram_AdjustsHeadingDepth diff --git a/docs/reqstream/sysml2-tools-tool/cli/context.yaml b/docs/reqstream/sysml2-tools-tool/cli/context.yaml index 16b57422..737500cf 100644 --- a/docs/reqstream/sysml2-tools-tool/cli/context.yaml +++ b/docs/reqstream/sysml2-tools-tool/cli/context.yaml @@ -42,18 +42,6 @@ sections: - Context_Create_DepthFlag_NonIntegerValue_ThrowsArgumentException - Context_Create_DepthFlag_ZeroValue_ThrowsArgumentException - - id: SysML2Tools-Tool-Context-MaxRenderDepth - title: >- - The Context class shall expose the raw --depth value as MaxRenderDepth (int?) - without clamping, so that render commands can apply an unlimited depth limit. - justification: | - HeadingDepth is clamped to the valid markdown heading range (1–6), but diagram - render depth is unbounded. Separating the two allows callers to apply each - independently. - tests: - - Context_Create_DepthFlag_SetsMaxRenderDepth - - Context_Create_DepthFlag_ExceedsMaxValue_SetsMaxRenderDepth - - id: SysML2Tools-Tool-Context-ViewName title: >- The Context class shall parse the --view flag into the ViewName property and @@ -157,8 +145,10 @@ sections: - Context_Create_QueryCommand_WithIncludeStdlibFlag_SetsIncludeStdlibTrue - Context_Create_QueryCommand_WithFormatMarkdown_SetsQueryFormat - Context_Create_QueryCommand_WithFormatJson_SetsQueryFormat - - Context_Create_QueryCommand_WithDepthFlag_SetsQueryDepth + - Context_Create_QueryCommand_WithWalkDepthFlag_SetsQueryWalkDepth - Context_Create_QueryCommand_WithFiles_SetsQueryFilesNotTopLevelFiles + - Context_Create_QueryCommand_WithoutHeading_LeavesHeadingNull + - Context_Create_QueryCommand_WithHeadingFlag_SetsHeading - id: SysML2Tools-Tool-Context-CommandScoping title: >- diff --git a/docs/reqstream/sysml2-tools-tool/help.yaml b/docs/reqstream/sysml2-tools-tool/help.yaml index 769a2366..17415b0d 100644 --- a/docs/reqstream/sysml2-tools-tool/help.yaml +++ b/docs/reqstream/sysml2-tools-tool/help.yaml @@ -46,9 +46,9 @@ sections: - id: SysML2Tools-Tool-Help-QueryOverview title: >- The help command, invoked as `help query` with no verb, shall print an overview of - all 11 query verbs with one-line summaries, identical to `query --help`. + all 12 query verbs with one-line summaries, identical to `query --help`. justification: | - `query` has substantially more surface area (11 verbs) than `lint`/`render`; + `query` has substantially more surface area (12 verbs) than `lint`/`render`; a verb overview lets users discover the full verb vocabulary before drilling into any one verb's specific flags. tests: @@ -56,12 +56,12 @@ sections: - id: SysML2Tools-Tool-Help-QueryVerbHelp title: >- - The help command, invoked as `help query ` for any of the 11 recognized + The help command, invoked as `help query ` for any of the 12 recognized verbs, shall print that verb's exact syntax, flags, and usage, identical to `query --help`. justification: | Each verb has a distinct flag set (e.g., `--direction` for hierarchy, `--kind`/ - `--name` for list/find, `--depth` for impact); verb-specific help is required for + `--name` for list/find, `--walk-depth` for impact); verb-specific help is required for users to discover the correct flags for the verb they intend to run. tests: - HelpSubsystem_HelpQueryVerb_MatchesQueryVerbHelpFlag diff --git a/docs/reqstream/sysml2-tools-tool/query.yaml b/docs/reqstream/sysml2-tools-tool/query.yaml index 6813517b..4b0a3dd4 100644 --- a/docs/reqstream/sysml2-tools-tool/query.yaml +++ b/docs/reqstream/sysml2-tools-tool/query.yaml @@ -4,7 +4,7 @@ # PURPOSE: # - Define requirements for the DemaConsulting.SysML2Tools.Tool Query subsystem # - QueryCommand implements the CLI query verb dispatcher (QueryVerb/QueryOptions support it) -# - QueryEngine implements the 11 verbs' real analysis logic against the semantic model +# - QueryEngine implements the 12 verbs' real analysis logic against the semantic model # - QueryResult/QueryResultRenderer/QueryResultSerializerContext implement the shared, # verb-agnostic Markdown/JSON output layer # - Requirements describe observable behavior of the query command's parsing, validation, @@ -16,8 +16,8 @@ sections: - id: SysML2Tools-Tool-Query-VerbGrammar title: >- The query command shall accept a verb token immediately following `query` and - recognize the 11 verbs uses, used-by, impact, describe, hierarchy, requirements, - interface, connections, states, list, and find. + recognize the 12 verbs uses, used-by, dependencies, impact, describe, hierarchy, + requirements, interface, connections, states, list, and find. justification: | A fixed, positional verb vocabulary keeps the query grammar simple and predictable for both human and AI-assisted callers, matching the verb set already fixed by @@ -94,6 +94,7 @@ sections: - Query_MarkdownAndJsonFormats_AgreeOnEntryContentAndOrder - RenderMarkdown_UnorderedEntries_SortsByQualifiedNameOrdinal - RenderJson_UnorderedEntries_SortsByQualifiedNameOrdinal + - RenderJson_NonDependenciesVerb_DirectionFieldOmittedFromOutput - id: SysML2Tools-Tool-Query-StdlibFilter title: >- @@ -140,10 +141,55 @@ sections: tests: - UsedBy_ReportsIncomingReferences + - id: SysML2Tools-Tool-Query-Dependencies + title: >- + The dependencies verb shall combine the uses (outgoing) and used-by (incoming) + results for the same target element into a single result, tagging each entry's + Direction field (Outgoing/Incoming) accordingly, and shall render its Markdown + output as prose bullets ("Depends on"/"Used by") grouped by direction rather than + the shared table used by every other verb; --depth/--heading shall apply to its + heading exactly as they do for every other verb. + justification: | + A combined, prose-rendered "what does this depend on, and what depends on it" + view is a distinct documentation-embedding need (e.g., a "Dependencies" section + in a generated design document) from the tabular verbs; reusing the uses/used-by + methods avoids duplicating the underlying edge-traversal logic, and confining the + Direction field's JSON serialization to only-when-non-null (via + JsonIgnore(WhenWritingNull)) keeps every other verb's JSON output unaffected. + tests: + - Dependencies_CombinesOutgoingAndIncoming_ReportsBothDirections + - Dependencies_NoOutgoingReferences_ReportsProseLineInsteadOfBulletList + - Dependencies_NoIncomingReferences_ReportsProseLineInsteadOfBulletList + - Dependencies_MarkdownOutput_ContainsNoTable + - RenderMarkdown_DependenciesVerb_RendersBulletProseNotTable + - RenderMarkdown_DependenciesVerb_EmptyOutgoingAndIncoming_ReportsBothProseLines + - RenderJson_DependenciesVerb_IncludesDirectionField + - RenderJson_NonDependenciesVerb_DirectionFieldOmittedFromOutput + - Dependencies_DepthAndHeadingOptions_ApplyToHeadingLikeOtherVerbs + + - id: SysML2Tools-Tool-Query-DependenciesNameShortening + title: >- + The dependencies verb's Markdown output shall shorten every name (the subject + element plus every entry's qualified name) by the longest leading "::"-segment + prefix shared across that combined pool, applied identically to the subject + sentence and both bullet groups, while its JSON output (and every other verb's + Markdown and JSON output) remains fully qualified and unaffected. + justification: | + A "Dependencies" prose section frequently relates several elements from the same + package hierarchy; repeating a long shared package prefix on every bullet and in + the subject sentence adds noise without adding information. Reusing the standalone + QualifiedNameShortener utility (see the Utilities subsystem) keeps this compaction + logic verb/format-agnostic and confined to the one Markdown-only rendering path + that already special-cases dependencies, so no other verb or output format is + affected. + tests: + - RenderMarkdown_DependenciesVerb_NoCommonPrefix_LeavesNamesFullyQualified + - RenderJson_DependenciesVerb_NamesRemainFullyQualified + - id: SysML2Tools-Tool-Query-Impact title: >- The impact verb shall compute the transitive closure of used-by edges from the - target element, optionally bounded by --depth, cycle-guarded against infinite + target element, optionally bounded by --walk-depth, cycle-guarded against infinite loops. justification: | A single-hop used-by view understates true blast radius for deeply-referenced @@ -256,7 +302,7 @@ sections: - id: SysML2Tools-Tool-Query-Help title: >- - The query command shall render general help listing all 11 verbs when invoked as + The query command shall render general help listing all 12 verbs when invoked as `query --help` with no verb, and verb-specific help when invoked as `query --help`, in both cases without requiring --element. justification: | @@ -280,6 +326,26 @@ sections: - ResxResource_EveryKey_ResolvesToNonEmptyText - ResxResource_KeysAndAccessorProperties_AreInBidirectionalParity + - id: SysML2Tools-Tool-Query-ReportHeading + title: >- + The query command shall accept --depth (the global heading-depth flag, an integer + 1-6, default 1) and --heading (custom text) options controlling the Markdown + output's top heading depth and text, with no effect on --format json output. + justification: | + Embedding query output into larger generated Markdown reports (e.g., appending a + query result under an existing document section) requires the ability to align + the heading depth with the surrounding document and to give the section a + document-specific title, matching the Depth/Heading precedent already + established by the ReviewMark and SarifMark CLIs. + tests: + - Context_Create_QueryCommand_WithoutHeading_LeavesHeadingNull + - Context_Create_QueryCommand_WithHeadingFlag_SetsHeading + - RenderMarkdown_DefaultArguments_ProducesUnchangedTopLevelHeading + - RenderMarkdown_CustomDepth_UsesThatManyHeadingHashes + - RenderMarkdown_CustomHeading_ReplacesAutoGeneratedText + - RenderMarkdown_CustomDepthAndHeading_CombinesBothOverrides + - QuerySubsystem_FormatJson_UnaffectedByDepthOrHeading + - id: SysML2Tools-Tool-Query-HelpEnrichment title: >- Verb-specific help (`query --help`) shall include one real example @@ -288,7 +354,7 @@ sections: include a "typical workflow" note recommending `list`/`find` before element-scoped verbs. justification: | - A fixed vocabulary of 11 verbs with deeply-nested qualified-name arguments is hard + A fixed vocabulary of 12 verbs with deeply-nested qualified-name arguments is hard to use from help text alone; a real, runnable example per verb and an explicit discovery-first workflow tip make the CLI directly usable without external documentation, especially for AI-assisted callers. diff --git a/docs/reqstream/sysml2-tools-tool/render.yaml b/docs/reqstream/sysml2-tools-tool/render.yaml index f3b20b13..353ca412 100644 --- a/docs/reqstream/sysml2-tools-tool/render.yaml +++ b/docs/reqstream/sysml2-tools-tool/render.yaml @@ -82,12 +82,12 @@ sections: - id: SysML2Tools-Tool-Render-DepthLimit title: >- The render command shall limit diagram nesting depth to the value supplied via - --depth, replacing truncated children with an ellipsis indicator. + --walk-depth, replacing truncated children with an ellipsis indicator. justification: | A depth limit prevents overly complex diagrams when large models have deep nesting. An ellipsis marker tells the reader that content was omitted. tests: - - RenderSubsystem_WithDepth_LimitsNesting + - RenderSubsystem_WithWalkDepth_LimitsNesting - id: SysML2Tools-Tool-Render-AllViewsExport title: >- diff --git a/docs/reqstream/sysml2-tools-tool/utilities.yaml b/docs/reqstream/sysml2-tools-tool/utilities.yaml index d1bcbe72..72b284dc 100644 --- a/docs/reqstream/sysml2-tools-tool/utilities.yaml +++ b/docs/reqstream/sysml2-tools-tool/utilities.yaml @@ -3,7 +3,8 @@ # # PURPOSE: # - Define requirements for the SysML2Tools Utilities subsystem -# - The Utilities subsystem spans PathHelpers.cs (safe path-combination utilities) +# - The Utilities subsystem spans PathHelpers.cs (safe path-combination utilities) and +# QualifiedNameShortener.cs (qualified-name common-prefix stripping for Markdown display) # - Subsystem requirements describe the shared utility behavior available to all other subsystems sections: @@ -24,3 +25,19 @@ sections: - UtilitiesSubsystem_DirectoryCreationWorkflow_ValidPaths_CreatesDirectories - UtilitiesSubsystem_PathTraversalValidation_DangerousPaths_ThrowsException - UtilitiesSubsystem_AbsolutePathRejection_ThrowsException + + - id: Template-Utilities-QualifiedNameShortening + title: >- + The Utilities subsystem shall provide Markdown-only qualified-name shortening that + strips the longest common leading "::"-segment prefix shared across a pool of + qualified names, always retaining each name's own leaf segment. + justification: | + The Utilities subsystem provides shared, reusable helpers consumed by other + subsystems. Rendering paths (such as the query command's Markdown output) benefit + from a common, centrally-tested prefix-shortening helper rather than each renderer + re-implementing its own ad hoc logic; this requirement captures the subsystem-level + capability that the QualifiedNameShortener unit implements. + tests: + - QualifiedNameShortener_Shorten_OneSharedLeadingSegment_StripsThatSegment + - QualifiedNameShortener_Shorten_NoCommonPrefix_LeavesNamesUnchanged + - QualifiedNameShortener_Shorten_SingleNamePool_LeavesNameUnchanged diff --git a/docs/reqstream/sysml2-tools-tool/utilities/qualified-name-shortener.yaml b/docs/reqstream/sysml2-tools-tool/utilities/qualified-name-shortener.yaml new file mode 100644 index 00000000..93e2eff7 --- /dev/null +++ b/docs/reqstream/sysml2-tools-tool/utilities/qualified-name-shortener.yaml @@ -0,0 +1,31 @@ +--- +# Software Unit Requirements for the QualifiedNameShortener Class +# +# The QualifiedNameShortener class strips the longest shared leading "::"-segment +# prefix from a pool of qualified names, always retaining each name's own leaf +# segment, so Markdown-oriented renderers can present related names more compactly. + +sections: + - title: QualifiedNameShortener Unit Requirements + requirements: + - id: Template-QualifiedNameShortener-CommonPrefix + title: >- + The QualifiedNameShortener class shall strip the longest leading "::"-segment + prefix shared by every name in a supplied pool of qualified names, always + retaining each name's own leaf segment. + justification: | + A pool of related qualified names (e.g., a query subject and its dependencies) + often shares a long common package prefix that adds no distinguishing value to + a reader; stripping that shared prefix - while never discarding a name's own + leaf segment - makes Markdown-oriented output more compact without losing + information. + tests: + - QualifiedNameShortener_Shorten_OneSharedLeadingSegment_StripsThatSegment + - QualifiedNameShortener_Shorten_NoCommonPrefix_LeavesNamesUnchanged + - QualifiedNameShortener_Shorten_SingleNamePool_LeavesNameUnchanged + - QualifiedNameShortener_Shorten_AllIdenticalNames_KeepsLeafSegment + - QualifiedNameShortener_Shorten_DeeperCommonPrefix_StripsAllSharedSegments + - QualifiedNameShortener_Shorten_ShortestNameBoundsCap_RetainsShortestNamesLeaf + - QualifiedNameShortener_Shorten_NullPool_ThrowsArgumentNullException + - QualifiedNameShortener_Shorten_NullEntryInPool_ThrowsArgumentNullException + - QualifiedNameShortener_Shorten_DuplicateNamesInPool_ReturnsOneEntryPerDistinctName diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 68772cb1..4ecdd996 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -390,12 +390,12 @@ definition. ## Depth Limiting -Use `--depth ` to limit the nesting depth rendered. Parts beyond the limit are replaced +Use `--walk-depth ` to limit the nesting depth rendered. Parts beyond the limit are replaced with an ellipsis footer (`+N more…`). Silent omission is never used — truncation is always visible in the output. ```bash -sysml2tools render model.sysml --output out --depth 3 +sysml2tools render model.sysml --output out --walk-depth 3 ``` ## Output Formats @@ -411,7 +411,7 @@ Windows, Linux, and macOS. # Querying The `query` command loads a workspace, resolves the semantic model, and answers -model-comprehension and analysis questions via 11 verbs. Every verb accepts +model-comprehension and analysis questions via 12 verbs. Every verb accepts `--format markdown` (default) or `--format json`, and `--include-stdlib` to include standard-library elements (excluded by default). Output is always sorted alphabetically by qualified name, regardless of format, for stable and reproducible results. @@ -428,8 +428,11 @@ sysml2tools query uses --element Model::Vehicle "src/**/*.sysml" # What depends on this element? (incoming edges) sysml2tools query used-by --element Model::Engine "src/**/*.sysml" +# Combined "Dependencies" section prose: what it depends on, and what depends on it +sysml2tools query dependencies --element Model::Engine "src/**/*.sysml" + # Transitive blast radius of a change, optionally bounded -sysml2tools query impact --element Model::Engine --depth 2 "src/**/*.sysml" +sysml2tools query impact --element Model::Engine --walk-depth 2 "src/**/*.sysml" # A single-element "fact sheet": kind, supertypes, typing, annotations, children sysml2tools query describe --element Model::Vehicle "src/**/*.sysml" @@ -452,13 +455,16 @@ sysml2tools query states --element Model::VehicleStates "src/**/*.sysml" # Enumerate elements matching a kind and/or name substring sysml2tools query list --kind requirement "src/**/*.sysml" sysml2tools query find --name Engine "src/**/*.sysml" --format json + +# Embed the report under a custom heading (e.g., when appending to a larger document) +sysml2tools query describe --element Model::Vehicle --depth 2 --heading "Vehicle Report" "src/**/*.sysml" ``` ## Query Output Formats | Format | Flag | Notes | | --- | --- | --- | -| Markdown | default, or `--format markdown` | Heading, summary bullets, table — readable by humans and LLMs | +| Markdown | default, or `--format markdown` | Heading, summary bullets, table (prose bullets for `dependencies`) | | JSON | `--format json` | Source-generated (AOT-safe) serialization of the same result shape | Markdown and JSON renderings of the same query always contain the same qualified names in @@ -470,7 +476,8 @@ the same order, so either format can be relied on for automated comparisons. | --- | --- | --- | | `uses` | yes | What does this element depend on? | | `used-by` | yes | What depends on this element? | -| `impact` | yes | What is transitively affected by a change (`--depth` to bound)? | +| `dependencies` | yes | What does this element depend on, and what depends on it (combined, as prose)? | +| `impact` | yes | What is transitively affected by a change (`--walk-depth` to bound)? | | `describe` | yes | What is this element (kind, supertypes, typing, annotations, children)? | | `hierarchy` | yes | What is the supertype/subtype tree (`--direction up`\|`down`\|`both`)? | | `requirements` | yes | What satisfy/verify/allocate relationships involve this element? | @@ -480,6 +487,20 @@ 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` | +## `query` Options + +| Option | Description | +| --- | --- | +| `--element `, `-e ` | Qualified name of the target element; required for every verb except `list`/`find` | +| `--format markdown\|json` | Output format (default: `markdown`); distinct from `render`'s `--format` (`svg`/`png`) | +| `--walk-depth <#>` | Maximum impact-walk depth (`impact` verb only) | +| `--direction up\|down\|both` | Traversal direction (`hierarchy` verb only) | +| `--kind ` | Element-kind filter (`list`/`find` verbs only) | +| `--name ` | Name substring filter (`list`/`find` verbs only) | +| `--include-stdlib` | Include OMG standard library elements in results | +| `--depth <#>` | Markdown heading depth (1-6, default: 1); Markdown output only, no effect on `--format json` | +| `--heading ` | Replaces default `# query [: ]`; Markdown only, no effect on JSON | + # Exporting The `export` command loads a workspace, resolves the semantic model, and dumps the *entire* diff --git a/docs/verification/sysml2-tools-tool/cli/context.md b/docs/verification/sysml2-tools-tool/cli/context.md index 7564d927..d2f0fa5c 100644 --- a/docs/verification/sysml2-tools-tool/cli/context.md +++ b/docs/verification/sysml2-tools-tool/cli/context.md @@ -104,15 +104,11 @@ is tested by `Context_Create_DepthFlag_ZeroValue_ThrowsArgumentException`. **Context_Create_DepthFlag_ExceedsMaxValue_ThrowsArgumentException**: *(replaced)* `Context.Create` with `["--depth", "7"]` no longer throws; this scenario is superseded by -`Context_Create_DepthFlag_ExceedsMaxValue_SetsMaxRenderDepth`. +`Context_Create_DepthFlag_ExceedsMaxValue_ClampsHeadingDepth`. -**Context_Create_DepthFlag_ExceedsMaxValue_SetsMaxRenderDepth**: `Context.Create` is called -with `["--depth", "7"]`; `HeadingDepth` is 6 (clamped) and `MaxRenderDepth` is 7 (raw). -This scenario is tested by `Context_Create_DepthFlag_ExceedsMaxValue_SetsMaxRenderDepth`. - -**Context_Create_DepthFlag_SetsMaxRenderDepth**: `Context.Create` is called with -`["--depth", "3"]`; `HeadingDepth` is 3 and `MaxRenderDepth` is 3. This scenario is tested -by `Context_Create_DepthFlag_SetsMaxRenderDepth`. +**Context_Create_DepthFlag_ExceedsMaxValue_ClampsHeadingDepth**: `Context.Create` is called +with `["--depth", "7"]`; `HeadingDepth` is 6 (clamped). This scenario is tested by +`Context_Create_DepthFlag_ExceedsMaxValue_ClampsHeadingDepth`. **Context_Create_ViewFlag_SetsViewName**: `Context.Create` is called with `["render", "--view", "MyView"]`; `Render.ViewName` equals `"MyView"`. This scenario is tested by @@ -149,7 +145,7 @@ scenario is tested by `Context_WriteError_WritesToLogFile`. scenario is tested by `Context_Create_LogFlag_InvalidPath_ThrowsInvalidOperationException`. **Context_Create_QueryCommand_WithVerbToken_SetsQueryVerb**: `Context.Create` is called with -`["query", , "--element", "Pkg::Foo"]` for each of the 11 recognized verb tokens; +`["query", , "--element", "Pkg::Foo"]` for each of the 12 recognized verb tokens; `Command` equals `SysmlCommand.Query` and `Query.Verb` matches the token. This scenario is tested by the `[Theory]` `Context_Create_QueryCommand_WithVerbToken_SetsQueryVerb`. @@ -199,10 +195,11 @@ tested by `Context_Create_QueryCommand_WithFormatMarkdown_SetsQueryFormat`. with `["query", "list", "--format", "json"]`; `Query.Format` equals `"json"`. This scenario is tested by `Context_Create_QueryCommand_WithFormatJson_SetsQueryFormat`. -**Context_Create_QueryCommand_WithDepthFlag_SetsQueryDepth**: `Context.Create` is called with -`["query", "impact", "--element", "Pkg::Foo", "--depth", "3"]`; `Query.Depth` equals 3 and -`MaxRenderDepth` equals 3 (same underlying parsed value). This scenario is tested by -`Context_Create_QueryCommand_WithDepthFlag_SetsQueryDepth`. +**Context_Create_QueryCommand_WithWalkDepthFlag_SetsQueryWalkDepth**: `Context.Create` is called with +`["query", "impact", "--element", "Pkg::Foo", "--walk-depth", "3"]`; `Query.WalkDepth` equals 3 +and `HeadingDepth` remains 1 (default), confirming `--walk-depth` and the global `--depth` are +distinct, unrelated flags. This scenario is tested by +`Context_Create_QueryCommand_WithWalkDepthFlag_SetsQueryWalkDepth`. **Context_Create_QueryCommand_WithFiles_SetsQueryFilesNotTopLevelFiles**: `Context.Create` is called with `["query", "list", "*.sysml"]`; `Query.Files` contains `"*.sysml"` while diff --git a/docs/verification/sysml2-tools-tool/help.md b/docs/verification/sysml2-tools-tool/help.md index 17155181..e50675ab 100644 --- a/docs/verification/sysml2-tools-tool/help.md +++ b/docs/verification/sysml2-tools-tool/help.md @@ -27,9 +27,9 @@ run against the tool's target framework. to "No command specified". - `help lint`/`help render` produce output identical to `lint --help`/`render --help` respectively, each containing that command's distinguishing usage text. -- `help query` (no verb) produces output identical to `query --help`, listing all 11 verbs. +- `help query` (no verb) produces output identical to `query --help`, listing all 12 verbs. - `help query ` produces output identical to `query --help`, for every one of - the 11 verbs, each mentioning that verb's real flags (e.g., `--depth` for `impact`, + the 12 verbs, each mentioning that verb's real flags (e.g., `--walk-depth` for `impact`, `--direction` for `hierarchy`, `--kind`/`--name` for `list`/`find`, `--element` for every other verb). - `help ` and `help query ` throw a graceful @@ -41,7 +41,7 @@ run against the tool's target framework. - Existing `-h`/`-?`/`--help` regression tests (`ProgramTests.cs`) continue to pass unmodified, confirming no regression in the pre-existing global-flag behavior. - `HelpArgumentParser.Parse` in isolation: no arguments → both fields null; `lint`/`render`/ - `query` (no verb) → `TargetCommand` set; `query` + each of the 11 verbs → `TargetVerb` set; + `query` (no verb) → `TargetCommand` set; `query` + each of the 12 verbs → `TargetVerb` set; unknown target/verb/extra token → `ArgumentException`. #### Test Scenarios @@ -56,8 +56,8 @@ leaves `TargetCommand`/`TargetVerb` both null. **`HelpArgumentParser_Parse_QueryNoVerb_SetsTargetCommandQueryOnly`**: Each of the three recognized target commands sets `TargetCommand` accordingly with `TargetVerb` null. -**`HelpArgumentParser_Parse_QueryWithVerb_SetsTargetVerb`** (theory, 11 cases): For each of -the 11 recognized query verb tokens, `help query ` sets both `TargetCommand` (`"query"`) +**`HelpArgumentParser_Parse_QueryWithVerb_SetsTargetVerb`** (theory, 12 cases): For each of +the 12 recognized query verb tokens, `help query ` sets both `TargetCommand` (`"query"`) and `TargetVerb` (the verb token). **`HelpArgumentParser_Parse_UnknownTargetCommand_ThrowsArgumentException`**: An unrecognized @@ -90,11 +90,11 @@ branch returns before `RunToolLogicAsync`'s default arm could run. containing that command's distinguishing usage substring. **`HelpSubsystem_HelpQuery_MatchesQueryHelpFlag`**: `help query` (no verb) produces output -identical to `query --help`, containing all 11 verb tokens. +identical to `query --help`, containing all 12 verb tokens. -**`HelpSubsystem_HelpQueryVerb_MatchesQueryVerbHelpFlag`** (theory, 11 cases): For each verb, +**`HelpSubsystem_HelpQueryVerb_MatchesQueryVerbHelpFlag`** (theory, 12 cases): For each verb, `help query ` produces output identical to `query --help`, containing the verb's -usage line and its real flag(s) (`--depth` for `impact`; `--direction` for `hierarchy`; +usage line and its real flag(s) (`--walk-depth` for `impact`; `--direction` for `hierarchy`; `--kind`/`--name` for `list`/`find`; `--element` for every other verb). **`HelpSubsystem_HelpUnknownCommand_ThrowsArgumentException`** / diff --git a/docs/verification/sysml2-tools-tool/query.md b/docs/verification/sysml2-tools-tool/query.md index d9602405..4471a1df 100644 --- a/docs/verification/sysml2-tools-tool/query.md +++ b/docs/verification/sysml2-tools-tool/query.md @@ -23,7 +23,7 @@ framework. #### Acceptance Criteria -- All 11 verb tokens parse to the matching `QueryVerb` and dispatch to real `QueryEngine` +- All 12 verb tokens parse to the matching `QueryVerb` and dispatch to real `QueryEngine` logic, producing exit code 0 for valid input. - An unrecognized verb token produces an `ArgumentException` naming the bad token. - `--element`/`-e` is required for every verb except `list`/`find`; omitting it for a verb @@ -32,15 +32,31 @@ framework. - `list`/`find` succeed without `--element`. - `--format markdown` and `--format json` both parse and render without error; an unrecognized `--format` value produces an `ArgumentException`. +- `--depth` (global flag, 1-6, default 1) and `--heading` (default: auto-generated text) + control the Markdown output's top heading depth/text without affecting `--format json` + output (byte-identical JSON with/without the flags). - Each verb's output correctly reflects the underlying model for representative inline fixtures: `uses` reports outgoing supertype/typing/import edges; `used-by` reports the - reverse; `impact` respects `--depth` bounding and computes the full transitive closure + reverse; `dependencies` combines `uses`/`used-by` for one element into a single prose + (non-tabular) Markdown result, with the merged entries' `Direction` field + (`Outgoing`/`Incoming`) populated only for this verb; `impact` respects `--walk-depth` + bounding and computes the full transitive closure when unbounded; `describe` reports kind, resolved supertypes, and child count; `hierarchy` respects `--direction up`/`down`/`both`; `requirements` reports satisfy/verify/allocate edges; `interface` reports ports/typed features and excludes plain attributes; `connections` reports resolved feature-chain endpoints with the connector's keyword; `states` reports states and guarded transitions; `list`/`find` respect `--kind`/`--name` filtering. +- `dependencies` reports the merged `uses`/`used-by` result as bullet-prose Markdown (not a + table), with entries' `Direction` field populated (`Outgoing`/`Incoming`) only for this + verb; JSON output for every other verb is unaffected (no `Direction` key present when + `null`). +- `dependencies`'s Markdown output shortens every name (the subject element plus every entry's + qualified name) by the longest shared leading `::`-segment prefix across that combined pool, + via `Utilities.QualifiedNameShortener.Shorten`, applied identically to the subject sentence + and both bullet groups; when the pool shares no common prefix, names remain fully qualified. + JSON output for `dependencies` (and every other verb) is unaffected — `QualifiedName`/ + `Element` values remain fully qualified in JSON regardless of this Markdown-only behavior. - Markdown and JSON renderings of the same `QueryResult` contain the same qualified names in the same (alphabetical, ordinal) order. - `--include-stdlib` toggles whether stdlib-seeded elements appear in results. @@ -67,14 +83,14 @@ framework. ##### QuerySubsystemTests.cs -**`QuerySubsystem_AnyVerb_WithValidInput_DispatchesToRealLogic`** (theory, 11 cases): +**`QuerySubsystem_AnyVerb_WithValidInput_DispatchesToRealLogic`** (theory, 12 cases): Verifies that each verb, given `--element` when required (and `--kind` for `find`), against a small shared fixture covering every verb's target element, produces exit code 0 and output containing `query {verb}`. ##### QuerySubsystem_ElementRequiredVerb_MissingElement_ThrowsArgumentException -Verifies that omitting `--element` for any of the 9 verbs that require it (all except +Verifies that omitting `--element` for any of the 10 verbs that require it (all except `list`/`find`) throws an `ArgumentException` mentioning `--element`. ##### QuerySubsystem_ListVerb_NoElementNoFiles_ReportsNoInputFilesError / QuerySubsystem_FindVerb_NoElementNoFiles_ReportsNoInputFilesError @@ -114,9 +130,9 @@ Verifies that `query --help` (no verb) includes the "typical workflow" note text actually rendered, not merely present in the resx file. Satisfies `SysML2Tools-Tool-Query-HelpEnrichment`. -##### QuerySubsystem_QueryVerbHelp_MentionsExampleInvocationAndSchemaHints (theory, 11 cases) +##### QuerySubsystem_QueryVerbHelp_MentionsExampleInvocationAndSchemaHints (theory, 12 cases) -Verifies that `query --help`, for every one of the 11 verbs, contains that verb's +Verifies that `query --help`, for every one of the 12 verbs, contains that verb's real example-invocation substring (drawn from the `VehicleExample` fixture, per the planning report's verified enrichment content) and the shared Markdown/JSON schema-hint substrings (`"Qualified Name"` for Markdown, `"QualifiedName"` for JSON — matching the real @@ -128,7 +144,7 @@ invocation during implementation). Satisfies `SysML2Tools-Tool-Query-HelpEnrichm For the `QueryStrings` resource base name/accessor pair (one of four covered by these theory tests), every key discovered in `Query/QueryStrings.resx`'s invariant-culture resource set resolves to non-null/non-empty text via `ResourceManager`, and every such key (including the -11 `Query_Example_*` keys, each backed by its own accessor property) has a matching +12 `Query_Example_*` keys, each backed by its own accessor property) has a matching `public static string` property on `QueryStrings` (and vice versa). Satisfies `SysML2Tools-Tool-Query-LocalizableHelpText`. @@ -137,9 +153,21 @@ resolves to non-null/non-empty text via `ResourceManager`, and every such key (i One or more `[Fact]`/`[Theory]` methods per verb, each using a small inline `.sysml` fixture written to a temp file and run end-to-end via `Context.Create` + `Program.RunAsync`, asserting on captured stdout content: qualified names of expected -entries, edge/kind labels, `--depth`/`--direction` bounding, annotation text, and +entries, edge/kind labels, `--walk-depth`/`--direction` bounding, annotation text, and `--include-stdlib` on/off behavior. +**`Dependencies_CombinesOutgoingAndIncoming_ReportsBothDirections`**, +**`Dependencies_NoOutgoingReferences_ReportsProseLineInsteadOfBulletList`**, +**`Dependencies_NoIncomingReferences_ReportsProseLineInsteadOfBulletList`**, +**`Dependencies_MarkdownOutput_ContainsNoTable`**: Verify, end-to-end, that `dependencies` +reports both a "Depends on" bullet (outgoing) and a "Used by" bullet (incoming) for an +element with both directions populated; that an element with no outgoing references +reports the single `"{Element} has no outgoing references."` prose line instead of a bullet +list; that an element with no incoming references reports the single `"No elements +reference {Element}."` prose line instead of a bullet list; and that the rendered Markdown +never contains the `"| Qualified Name | Kind | Detail |"` table header used by every other +verb. Satisfies `SysML2Tools-Tool-Query-Dependencies`. + ##### QueryRenderingTests.cs Direct unit tests of `QueryResultRenderer.RenderMarkdown`/`RenderJson` against hand-built @@ -148,6 +176,46 @@ correctness, `Detail`/`Notes` rendering, and JSON round-trip via `QueryResultSerializerContext`. Plus one end-to-end test confirming Markdown and JSON outputs for the same query contain the same qualified names in the same order. +**`RenderMarkdown_DefaultArguments_ProducesUnchangedTopLevelHeading`**, +**`RenderMarkdown_CustomDepth_UsesThatManyHeadingHashes`**, +**`RenderMarkdown_CustomHeading_ReplacesAutoGeneratedText`**, +**`RenderMarkdown_CustomDepthAndHeading_CombinesBothOverrides`**: Verify that +`RenderMarkdown`'s default arguments produce the unchanged single `#` heading; that a custom +`depth` changes the number of leading `#` characters; that a custom `heading` +replaces the auto-generated heading text entirely (no merging with verb/element info); and +that both overrides combine correctly when supplied together. + +**`RenderMarkdown_DependenciesVerb_RendersBulletProseNotTable`**, +**`RenderMarkdown_DependenciesVerb_EmptyOutgoingAndIncoming_ReportsBothProseLines`**, +**`RenderJson_DependenciesVerb_IncludesDirectionField`**, +**`RenderJson_NonDependenciesVerb_DirectionFieldOmittedFromOutput`**, +**`Dependencies_DepthAndHeadingOptions_ApplyToHeadingLikeOtherVerbs`**: Unit-test +`dependencies`'s prose-bullet rendering directly against a hand-built, intentionally +unordered `QueryResult` (confirming per-direction ordinal sorting and the exact bullet/intro +text, now using shortened names since the fixture's subject and entries share the common +leading segment `"Model"`); confirm the both-directions-empty edge case reports both prose +lines with no bullets and no `"_No entries._"` fallback; confirm `RenderJson` includes a +populated `Direction` field (`Outgoing`/`Incoming`) for `dependencies` entries; confirm — the +critical regression test — that `RenderJson` for a non-`dependencies` verb (`uses`) never +contains the substring `"Direction"` in its JSON output, proving the `[JsonIgnore(Condition = +JsonIgnoreCondition.WhenWritingNull)]` attribute keeps every other verb's JSON output +unaffected by the new field; and confirm `--depth`/`--heading` apply to `dependencies`'s +heading line exactly like every other verb (now asserting the shortened bullet text). Satisfies +`SysML2Tools-Tool-Query-Dependencies`. + +**`RenderMarkdown_DependenciesVerb_NoCommonPrefix_LeavesNamesFullyQualified`**: Confirms that +when the subject element and its entries share no common leading segment (different top-level +packages), `dependencies`'s Markdown output leaves every name fully qualified in both the +subject sentence and the bullets. Satisfies +`SysML2Tools-Tool-Query-DependenciesNameShortening`. + +**`RenderJson_DependenciesVerb_NamesRemainFullyQualified`**: Explicit before/after regression +test — for a single hand-built `dependencies` `QueryResult` whose subject and entries share the +common leading segment `"Model"` (which Markdown shortens), confirms that `RenderJson`'s output +for the very same result keeps every `QualifiedName`/`Element` value fully qualified, proving +the shortening applies only to `RenderMarkdown`. Satisfies +`SysML2Tools-Tool-Query-DependenciesNameShortening`. + ##### QueryOmgFixtureTests.cs Loads real OMG training/example `.sysml` files via `WorkspaceLoader.LoadAsync` directly @@ -165,11 +233,17 @@ fixture test `QueryVerbsTests.States_ReportsStatesAndGuardedTransitions` (using `transition first X if G then Y;` syntax) validates both `"state"` and `"transition"` entry kinds together and is unaffected by the gap. +##### QuerySubsystem_FormatJson_UnaffectedByDepthOrHeading (QuerySubsystemTests.cs) + +Verifies that `--format json` output is byte-identical whether or not `--depth`/ +`--heading` are also supplied, confirming those two options are Markdown-output-only. + ##### QueryErrorPathTests.cs Covers: element not found (`context.WriteError` message contains "not found in the workspace", exit code 1); `find` without `--kind`/`--name` (`ArgumentException`); -unsupported `--format` value (`ArgumentException`); a file with parse errors (diagnostics +unsupported `--format` value (`ArgumentException`); a non-integer `--walk-depth` value +(`ArgumentException` naming `--walk-depth`); a file with parse errors (diagnostics reported, command completes best-effort); no input files supplied (exit code 1); a file pattern that matches no file on disk (`context.WriteError` message contains "no files matched", exit code 1, regression test for the glob-expansion bug fix — see @@ -179,7 +253,7 @@ multiple files instead of being treated as a literal, never-matching file name). ##### Context_Create_QueryCommand_WithVerbToken_SetsQueryVerb (ContextTests.cs) -Verifies, for each of the 11 verb tokens, that `Context.Create(["query", token, "--element", +Verifies, for each of the 12 verb tokens, that `Context.Create(["query", token, "--element", "Pkg::Foo"])` sets `Command` to `SysmlCommand.Query` and `Query.Verb` to the matching value. ##### Context_Create_QueryCommand_UnknownVerb_ThrowsArgumentException (ContextTests.cs) @@ -214,10 +288,18 @@ Verifies that `--format markdown`/`--format json` populate `Query.Format`, and t interpreted independently of render's `--format` (they are separate typed properties, not a shared field). -##### Context_Create_QueryCommand_WithDepthFlag_SetsQueryDepth (ContextTests.cs) +##### Context_Create_QueryCommand_WithWalkDepthFlag_SetsQueryWalkDepth (ContextTests.cs) + +Verifies that `--walk-depth 3` populates `Query.WalkDepth` without affecting the global +`Context.HeadingDepth` (which remains at its default of 1, since `--walk-depth` and +`--depth` are distinct, unrelated flags). + +##### Context_Create_QueryCommand_WithoutHeading_LeavesHeadingNull (ContextTests.cs) + +##### Context_Create_QueryCommand_WithHeadingFlag_SetsHeading (ContextTests.cs) -Verifies that `--depth 3` populates both `Query.Depth` and `MaxRenderDepth` (the same -underlying parsed value, interpreted independently by `query` vs `render`). +Verifies that `--heading` defaults to `null` when not supplied, and populates +`Query.Heading` when supplied. ##### Context_Create_QueryCommand_WithFiles_SetsQueryFilesNotTopLevelFiles (ContextTests.cs) diff --git a/docs/verification/sysml2-tools-tool/render.md b/docs/verification/sysml2-tools-tool/render.md index bc85f0a2..ce472e27 100644 --- a/docs/verification/sysml2-tools-tool/render.md +++ b/docs/verification/sysml2-tools-tool/render.md @@ -31,7 +31,7 @@ on context output and exit code. File-writing scenarios use a temporary director - Output files written to `--output` directory - No output files written when workspace has no views - Informational message written when workspace has no views -- `--depth 1` produces SVG output containing the ellipsis character `"…"` +- `--walk-depth 1` produces SVG output containing the ellipsis character `"…"` - Multiple views without `--view` renders every declared view, producing one output file per view, exit code 0 - Two views whose display names sanitize to the same output file name (declared in different @@ -122,9 +122,9 @@ directory. Verifies that a model with no view declarations produces no output files and an informational message. -#### RenderSubsystem_WithDepth_LimitsNesting +#### RenderSubsystem_WithWalkDepth_LimitsNesting -Verifies that `--depth 1` causes the SVG output to contain the ellipsis character `"…"`, +Verifies that `--walk-depth 1` causes the SVG output to contain the ellipsis character `"…"`, confirming that child part-def boxes were replaced by the depth-limit indicator. #### RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews diff --git a/docs/verification/sysml2-tools-tool/utilities.md b/docs/verification/sysml2-tools-tool/utilities.md index d1fde706..139eea31 100644 --- a/docs/verification/sysml2-tools-tool/utilities.md +++ b/docs/verification/sysml2-tools-tool/utilities.md @@ -3,10 +3,14 @@ ### Verification Approach The `Utilities` subsystem is verified by integration tests defined in -`UtilitiesSubsystemTests.cs`. Each test exercises `PathHelpers` through realistic -path-combination workflows to confirm that valid paths are resolved correctly, traversal attacks -are rejected, and the resulting paths can be used for actual directory creation. `PathHelpers` -has no dependencies on other tool units, so no mocking is required. +`UtilitiesSubsystemTests.cs` (covering `PathHelpers`) plus dedicated unit tests for each unit: +`PathHelpersTests.cs` and `QualifiedNameShortenerTests.cs`. Integration tests exercise +`PathHelpers` through realistic path-combination workflows to confirm that valid paths are +resolved correctly, traversal attacks are rejected, and the resulting paths can be used for +actual directory creation. `QualifiedNameShortener`'s only consumer (`QueryResultRenderer`) is +already covered end-to-end by the `Query` subsystem's own test suite, so no subsystem-level +integration test is added for it — see `docs/verification/sysml2-tools-tool/query.md`. Neither +unit has a dependency on any other tool unit, so no mocking is required for either. ### Test Environment @@ -19,6 +23,8 @@ N/A - standard test environment. - Path traversal patterns (e.g., `../`) cause `ArgumentException` to be thrown. - Absolute paths supplied as the relative argument cause `ArgumentException` to be thrown. - Paths produced by `SafePathCombine` can be passed directly to `Directory.CreateDirectory`. +- `QualifiedNameShortener.Shorten`'s unit-level acceptance criteria are documented separately in + `docs/verification/sysml2-tools-tool/utilities/qualified-name-shortener.md`. ### Test Scenarios diff --git a/docs/verification/sysml2-tools-tool/utilities/qualified-name-shortener.md b/docs/verification/sysml2-tools-tool/utilities/qualified-name-shortener.md new file mode 100644 index 00000000..b9059c7f --- /dev/null +++ b/docs/verification/sysml2-tools-tool/utilities/qualified-name-shortener.md @@ -0,0 +1,74 @@ +### QualifiedNameShortener + +#### Verification Approach + +`QualifiedNameShortener` is verified with unit tests defined in `QualifiedNameShortenerTests.cs`. +Because `QualifiedNameShortener` performs pure string manipulation using only .NET BCL types, no +mocking or test doubles are required. Tests call `QualifiedNameShortener.Shorten` directly with +controlled pools of qualified names and assert on the returned map or the thrown exception type. + +#### Test Environment + +N/A - standard test environment. + +#### Acceptance Criteria + +- All unit tests pass with zero failures. +- A pool of names sharing one leading segment has that segment stripped from every name. +- A pool of names sharing no common leading segment is returned unchanged. +- A pool containing a single distinct name is returned unchanged. +- A pool of all-identical names reduces to a single distinct name (per the "fewer than 2 + distinct names" edge case) and is returned unchanged, so its leaf segment is never stripped + to an empty string. +- A pool sharing a deeper (2+ segment) common prefix has every shared segment stripped. +- The common-prefix length is capped at the shortest name's segment count minus 1, so a short + name in the pool is never stripped down to nothing even when a longer name shares more + leading segments than the short name has to spare. +- Null pools and null pool entries cause `ArgumentNullException`. +- Duplicate names in the pool produce exactly one entry per distinct name in the returned map. + +#### Test Scenarios + +**QualifiedNameShortener_Shorten_OneSharedLeadingSegment_StripsThatSegment**: The worked example +`["A::B::x", "A::B::y", "A::T::g"]` is shortened; the shared leading segment `"A"` is stripped +from every name, producing `["B::x", "B::y", "T::g"]`. This scenario is tested by +`QualifiedNameShortener_Shorten_OneSharedLeadingSegment_StripsThatSegment`. + +**QualifiedNameShortener_Shorten_NoCommonPrefix_LeavesNamesUnchanged**: A pool of names rooted +in different top-level packages (`["A::B::x", "C::D::y"]`) is shortened; every name is returned +unchanged since no leading segment is shared. This scenario is tested by +`QualifiedNameShortener_Shorten_NoCommonPrefix_LeavesNamesUnchanged`. + +**QualifiedNameShortener_Shorten_SingleNamePool_LeavesNameUnchanged**: A pool containing only +one distinct name is shortened; the name is returned unchanged since there is nothing to compare +it against. This scenario is tested by +`QualifiedNameShortener_Shorten_SingleNamePool_LeavesNameUnchanged`. + +**QualifiedNameShortener_Shorten_AllIdenticalNames_KeepsLeafSegment**: A pool where every entry +is the same name (`["A::B::x", "A::B::x", "A::B::x"]`) reduces to a single distinct name and is +returned unchanged, confirming the leaf segment `"x"` is never stripped down to an empty string. +This scenario is tested by `QualifiedNameShortener_Shorten_AllIdenticalNames_KeepsLeafSegment`. + +**QualifiedNameShortener_Shorten_DeeperCommonPrefix_StripsAllSharedSegments**: A pool sharing the +two leading segments `"A::B"` (`["A::B::C::x", "A::B::C::y", "A::B::D::z"]`) is shortened; both +shared segments are stripped from every name. This scenario is tested by +`QualifiedNameShortener_Shorten_DeeperCommonPrefix_StripsAllSharedSegments`. + +**QualifiedNameShortener_Shorten_ShortestNameBoundsCap_RetainsShortestNamesLeaf**: A pool +containing `"A::B"` (2 segments) alongside `"A::B::C"` (which shares the 2-segment prefix +`"A::B"`) is shortened; only 1 segment (`"A"`) is stripped, capped by `"A::B"`'s own segment +count, so `"A::B"` becomes `"B"` rather than an empty string. This scenario is tested by +`QualifiedNameShortener_Shorten_ShortestNameBoundsCap_RetainsShortestNamesLeaf`. + +**QualifiedNameShortener_Shorten_NullPool_ThrowsArgumentNullException**: `null` is passed as the +`qualifiedNames` argument; an `ArgumentNullException` is thrown. This scenario is tested by +`QualifiedNameShortener_Shorten_NullPool_ThrowsArgumentNullException`. + +**QualifiedNameShortener_Shorten_NullEntryInPool_ThrowsArgumentNullException**: A pool +containing a `null` entry is passed; an `ArgumentNullException` is thrown. This scenario is +tested by `QualifiedNameShortener_Shorten_NullEntryInPool_ThrowsArgumentNullException`. + +**QualifiedNameShortener_Shorten_DuplicateNamesInPool_ReturnsOneEntryPerDistinctName**: A pool +where one name is repeated (`["A::B::x", "A::B::x", "A::T::g"]`) is shortened; the returned map +contains exactly one entry per distinct name, both correctly shortened. This scenario is tested +by `QualifiedNameShortener_Shorten_DuplicateNamesInPool_ReturnsOneEntryPerDistinctName`. diff --git a/requirements.yaml b/requirements.yaml index 9e8d1029..52649365 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -53,6 +53,7 @@ includes: - docs/reqstream/sysml2-tools-tool/self-test/validation.yaml - docs/reqstream/sysml2-tools-tool/utilities.yaml - docs/reqstream/sysml2-tools-tool/utilities/path-helpers.yaml + - docs/reqstream/sysml2-tools-tool/utilities/qualified-name-shortener.yaml - docs/reqstream/sysml2-tools-tool/platform-requirements.yaml - docs/reqstream/ots/antlr4.yaml - docs/reqstream/ots/dema-rendering.yaml diff --git a/src/DemaConsulting.SysML2Tools.Tool/Cli/Context.cs b/src/DemaConsulting.SysML2Tools.Tool/Cli/Context.cs index 8c66739e..1cd61a58 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Cli/Context.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Cli/Context.cs @@ -85,12 +85,6 @@ internal sealed class Context : IDisposable /// public int HeadingDepth { get; private init; } = 1; - /// - /// Gets the maximum diagram render depth; means unlimited. - /// Supplied via --depth and passed through directly (not clamped to 6). - /// - public int? MaxRenderDepth { get; private init; } - /// /// Gets the parsed options for the lint command; unless /// is . @@ -173,7 +167,7 @@ public static Context Create(string[] args) break; case SysmlCommand.Query: - queryOptions = QueryArgumentParser.Parse(global.CommandArgs, global.Help, global.MaxRenderDepth); + queryOptions = QueryArgumentParser.Parse(global.CommandArgs, global.Help); break; case SysmlCommand.Export: @@ -205,7 +199,6 @@ public static Context Create(string[] args) Validate = global.Validate, ResultsFile = global.ResultsFile, HeadingDepth = global.HeadingDepth, - MaxRenderDepth = global.MaxRenderDepth, Command = global.Command, Lint = lintOptions, Render = renderOptions, diff --git a/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArgumentParser.cs b/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArgumentParser.cs index 9ed142c5..edbb9ab8 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArgumentParser.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArgumentParser.cs @@ -48,7 +48,6 @@ public static GlobalArguments Parse(string[] args) string? resultsFile = null; string? logFile = null; var headingDepth = 1; - int? maxRenderDepth = null; var command = SysmlCommand.None; var commandArgs = new List(); @@ -89,7 +88,6 @@ public static GlobalArguments Parse(string[] args) case "--depth": var depth = CliArgumentHelpers.GetRequiredIntArgument(arg, args, ref index, "a heading depth argument", 1); headingDepth = Math.Clamp(depth, 1, 6); - maxRenderDepth = depth; break; // The command token is only recognized on its first occurrence; once a command @@ -132,7 +130,6 @@ public static GlobalArguments Parse(string[] args) ResultsFile = resultsFile, LogFile = logFile, HeadingDepth = headingDepth, - MaxRenderDepth = maxRenderDepth, Command = command, CommandArgs = commandArgs }; diff --git a/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArguments.cs b/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArguments.cs index f8df5b64..e22262bd 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArguments.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Cli/GlobalArguments.cs @@ -26,11 +26,12 @@ namespace DemaConsulting.SysML2Tools.Cli; /// arguments for the selected command's dedicated parser to interpret. /// /// -/// --depth is deliberately included here (not scoped to render's parser alone) -/// because it must remain usable with no command at all (feeding -/// during self-validation), in addition to feeding render's diagram depth and query's -/// impact-walk depth. See docs/design/sysml2-tools-tool/cli/context.md for the -/// full rationale. +/// --depth is deliberately included here (not scoped to any single command's parser) +/// because it must remain usable with no command at all, feeding +/// for the self-validation report heading (--validate). It also feeds query's +/// Markdown report heading depth. It is unrelated to the per-command --walk-depth flag +/// (impact-walk/diagram-nesting depth), which query and render parse locally. +/// See docs/design/sysml2-tools-tool/cli/context.md for the full rationale. /// internal sealed record GlobalArguments { @@ -69,11 +70,6 @@ internal sealed record GlobalArguments /// public int HeadingDepth { get; init; } = 1; - /// - /// Gets the maximum diagram/impact-walk render depth; means unlimited. - /// - public int? MaxRenderDepth { get; init; } - /// /// Gets the top-level command selected by the user. /// diff --git a/src/DemaConsulting.SysML2Tools.Tool/Program.cs b/src/DemaConsulting.SysML2Tools.Tool/Program.cs index 32ba54de..4dbec10f 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Program.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Program.cs @@ -225,7 +225,6 @@ internal static void PrintTopLevelHelp(Context context) context.WriteLine(ProgramStrings.TopLevel_OptionResults); context.WriteLine(ProgramStrings.TopLevel_OptionDepth1); context.WriteLine(ProgramStrings.TopLevel_OptionDepth2); - context.WriteLine(ProgramStrings.TopLevel_OptionDepth3); context.WriteLine(ProgramStrings.TopLevel_OptionLog); context.WriteLine(ProgramStrings.TopLevel_OptionOutput); context.WriteLine(ProgramStrings.TopLevel_OptionFormat1); diff --git a/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.cs b/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.cs index 08f4b7b7..7a1b3da3 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.cs @@ -106,9 +106,6 @@ internal static class ProgramStrings /// Gets the second line of the --depth option description. public static string TopLevel_OptionDepth2 => ResourceManager.GetString(nameof(TopLevel_OptionDepth2))!; - /// Gets the third line of the --depth option description. - public static string TopLevel_OptionDepth3 => ResourceManager.GetString(nameof(TopLevel_OptionDepth3))!; - /// Gets the --log option line. public static string TopLevel_OptionLog => ResourceManager.GetString(nameof(TopLevel_OptionLog))!; diff --git a/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.resx index 021c2f16..29759790 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.resx +++ b/src/DemaConsulting.SysML2Tools.Tool/ProgramStrings.resx @@ -116,13 +116,10 @@ --results <file> Write validation results to file (.trx or .xml) - --depth <#> Set heading depth (1–6) and diagram render depth + --depth <#> Set heading depth (1–6) for validation output and - (default: 1); for the query command's 'impact' verb, - - - --depth instead bounds the impact-walk depth + for the query command's Markdown reports (default: 1) --log <file> Write output to log file diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryArgumentParser.cs b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryArgumentParser.cs index 1b94fb78..d22bd463 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryArgumentParser.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryArgumentParser.cs @@ -34,11 +34,18 @@ namespace DemaConsulting.SysML2Tools.Query; /// instead); when no verb is present and --help was not requested, a clear /// is thrown rather than leaving the command in a silent /// null/None state. Remaining tokens recognize --element/-e, --direction, -/// --kind, --name, --include-stdlib, and --format, plus positional -/// file glob patterns; any other --prefixed token is rejected so that flags belonging to +/// --kind, --name, --include-stdlib, --format, +/// --walk-depth, and --heading, plus positional file glob patterns; +/// any other --prefixed token is rejected so that flags belonging to /// other commands (e.g., --auto, --output) are never silently accepted. /// --format's value is captured raw and validated later by /// , exactly as it already was before this refactor. +/// --walk-depth (impact-walk depth, unbounded) is a command-scoped flag parsed here, +/// distinct from the global --depth flag (Markdown heading depth, read directly from +/// by , not parsed +/// by this class). --heading (custom Markdown heading text) is also recognized here; +/// both --depth and --heading are Markdown-output-only and have no effect on +/// --format json. /// internal static class QueryArgumentParser { @@ -53,10 +60,6 @@ internal static class QueryArgumentParser /// when the global --help/-h/-? flag was /// supplied; suppresses the "verb is required" error when no verb token is present. /// - /// - /// The global --depth value (shared with render's diagram depth), threaded - /// through into . - /// /// /// The parsed , or when no verb token was /// supplied and is (e.g., @@ -67,7 +70,7 @@ internal static class QueryArgumentParser /// ; when the first token is not a recognized verb; or when an /// unrecognized flag is supplied. /// - public static QueryOptions? Parse(IReadOnlyList commandArgs, bool helpRequested, int? depth) + public static QueryOptions? Parse(IReadOnlyList commandArgs, bool helpRequested) { // The verb is a required structural first argument, validated strictly here rather than // lazily inferred by a shared default case. @@ -98,6 +101,8 @@ internal static class QueryArgumentParser string? kind = null; string? nameFilter = null; string? format = null; + int? walkDepth = null; + string? heading = null; var includeStdlib = false; var files = new List(); @@ -132,6 +137,16 @@ internal static class QueryArgumentParser arg, commandArgs, ref index, "a format argument (markdown or json)"); break; + case "--walk-depth": + walkDepth = CliArgumentHelpers.GetRequiredIntArgument( + arg, commandArgs, ref index, "an impact-walk depth argument", 1); + break; + + case "--heading": + heading = CliArgumentHelpers.GetRequiredStringArgument( + arg, commandArgs, ref index, "a heading text argument"); + break; + case "--include-stdlib": includeStdlib = true; break; @@ -153,11 +168,12 @@ internal static class QueryArgumentParser Verb = verb, Element = element, Format = format, - Depth = depth, + WalkDepth = walkDepth, Direction = direction, Kind = kind, NameFilter = nameFilter, IncludeStdlib = includeStdlib, + Heading = heading, Files = files }; } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryCommand.cs index e3303ccb..e19d0a0b 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryCommand.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryCommand.cs @@ -30,7 +30,7 @@ namespace DemaConsulting.SysML2Tools.Query; /// /// Implements the query command: loads a SysML v2 workspace and dispatches to one of -/// eleven model-analysis verbs implemented by , rendering the result +/// twelve model-analysis verbs implemented by , rendering the result /// via as Markdown (default) or JSON. /// internal static class QueryCommand @@ -138,6 +138,7 @@ public static async Task RunAsync(Context context) { QueryVerb.Uses => QueryEngine.Uses(workspace, element!, options), QueryVerb.UsedBy => QueryEngine.UsedBy(workspace, element!, options), + QueryVerb.Dependencies => QueryEngine.Dependencies(workspace, element!, options), QueryVerb.Impact => QueryEngine.Impact(workspace, element!, options), QueryVerb.Describe => QueryEngine.Describe(workspace, element!, options), QueryVerb.Hierarchy => QueryEngine.Hierarchy(workspace, element!, options), @@ -158,7 +159,8 @@ public static async Task RunAsync(Context context) } else { - foreach (var line in QueryResultRenderer.RenderMarkdown(result)) + foreach (var line in QueryResultRenderer.RenderMarkdown( + result, context.HeadingDepth, options.Heading)) { context.WriteLine(line); } @@ -176,6 +178,7 @@ public static void PrintGeneralHelp(Context context) context.WriteLine(QueryStrings.Query_VerbsHeader); context.WriteLine(QueryStrings.Query_VerbUses); context.WriteLine(QueryStrings.Query_VerbUsedBy); + context.WriteLine(QueryStrings.Query_VerbDependencies); context.WriteLine(QueryStrings.Query_VerbImpact); context.WriteLine(QueryStrings.Query_VerbDescribe); context.WriteLine(QueryStrings.Query_VerbHierarchy); @@ -192,9 +195,12 @@ public static void PrintGeneralHelp(Context context) context.WriteLine(QueryStrings.Query_GeneralOptionFormat1); context.WriteLine(QueryStrings.Query_GeneralOptionFormat2); context.WriteLine(QueryStrings.Query_GeneralOptionFormat3); + context.WriteLine(QueryStrings.Query_GeneralOptionWalkDepth1); + context.WriteLine(QueryStrings.Query_GeneralOptionWalkDepth2); context.WriteLine(QueryStrings.Query_GeneralOptionDepth1); context.WriteLine(QueryStrings.Query_GeneralOptionDepth2); - context.WriteLine(QueryStrings.Query_GeneralOptionDepth3); + context.WriteLine(QueryStrings.Query_GeneralOptionHeading1); + context.WriteLine(QueryStrings.Query_GeneralOptionHeading2); context.WriteLine(QueryStrings.Query_GeneralOptionDirection); context.WriteLine(QueryStrings.Query_GeneralOptionKind); context.WriteLine(QueryStrings.Query_GeneralOptionName); @@ -204,6 +210,7 @@ public static void PrintGeneralHelp(Context context) context.WriteLine(QueryStrings.Query_WorkflowNote2); context.WriteLine(QueryStrings.Query_WorkflowNote3); context.WriteLine(QueryStrings.Query_WorkflowNote4); + context.WriteLine(QueryStrings.Query_WorkflowNote5); } /// @@ -230,7 +237,11 @@ public static void PrintVerbHelp(Context context, QueryVerb verb) switch (verb) { case QueryVerb.Impact: - context.WriteLine(QueryStrings.Query_OptionDepthImpact); + context.WriteLine(QueryStrings.Query_OptionWalkDepthImpact); + break; + + case QueryVerb.Dependencies: + context.WriteLine(QueryStrings.Query_OptionWalkDepthIgnoredDependencies); break; case QueryVerb.Hierarchy: @@ -245,12 +256,22 @@ public static void PrintVerbHelp(Context context, QueryVerb verb) } context.WriteLine(QueryStrings.Query_OptionFormatVerb); + context.WriteLine(QueryStrings.Query_OptionDepthVerb); + context.WriteLine(QueryStrings.Query_OptionHeadingVerb); context.WriteLine(QueryStrings.Query_OptionIncludeStdlibVerb); context.WriteLine(""); context.WriteLine(QueryStrings.Query_ExampleHeader); context.WriteLine(QueryStrings.GetExample(verb)); context.WriteLine(""); - context.WriteLine(QueryStrings.Query_SchemaHint_Markdown); - context.WriteLine(QueryStrings.Query_SchemaHint_Json); + if (verb == QueryVerb.Dependencies) + { + context.WriteLine(QueryStrings.Query_SchemaHint_Markdown_Dependencies); + context.WriteLine(QueryStrings.Query_SchemaHint_Json_Dependencies); + } + else + { + context.WriteLine(QueryStrings.Query_SchemaHint_Markdown); + context.WriteLine(QueryStrings.Query_SchemaHint_Json); + } } } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryEngine.cs b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryEngine.cs index f5cad75b..650cd9eb 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryEngine.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryEngine.cs @@ -24,7 +24,7 @@ namespace DemaConsulting.SysML2Tools.Query; /// -/// Implements the real analysis logic for all 11 operations, each as +/// Implements the real analysis logic for all 12 operations, each as /// a static method taking the loaded , the resolved target /// ( only for / /// ), and the parsed , and returning a uniform @@ -124,9 +124,38 @@ public static QueryResult UsedBy(SysmlWorkspace workspace, SysmlNode element, Qu }; } + /// + /// Combines (outgoing) and (incoming) for a given + /// element into one result, tagging each entry's + /// accordingly, so the caller doesn't need to run two separate queries or duplicate the + /// underlying edge-traversal logic. + /// + /// The loaded workspace. + /// The target element. + /// The parsed query options. + /// The query result. + public static QueryResult Dependencies(SysmlWorkspace workspace, SysmlNode element, QueryOptions options) + { + var qualifiedName = QualifiedNameOf(element, options); + + var usesResult = Uses(workspace, element, options); + var usedByResult = UsedBy(workspace, element, options); + + var entries = new List(); + entries.AddRange(usesResult.Entries.Select(e => e with { Direction = QueryEntryDirection.Outgoing })); + entries.AddRange(usedByResult.Entries.Select(e => e with { Direction = QueryEntryDirection.Incoming })); + + return new QueryResult + { + Verb = "dependencies", + Element = qualifiedName, + Entries = entries + }; + } + /// /// Reports the transitive "blast radius" of a change to a given element: the transitive - /// closure of , bounded by when + /// closure of , bounded by when /// specified (unlimited otherwise). /// /// The loaded workspace. @@ -141,7 +170,7 @@ public static QueryResult Impact(SysmlWorkspace workspace, SysmlNode element, Qu var frontier = new List { qualifiedName }; var depth = 0; - while (frontier.Count > 0 && (options.Depth is not { } maxDepth || depth < maxDepth)) + while (frontier.Count > 0 && (options.WalkDepth is not { } maxDepth || depth < maxDepth)) { depth++; var next = new List(); @@ -175,7 +204,7 @@ public static QueryResult Impact(SysmlWorkspace workspace, SysmlNode element, Qu frontier = next; } - var depthSuffix = options.Depth is { } d ? $" (depth <= {d})" : string.Empty; + var depthSuffix = options.WalkDepth is { } d ? $" (depth <= {d})" : string.Empty; return new QueryResult { Verb = "impact", diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryOptions.cs b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryOptions.cs index 4100804d..921a4bbf 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryOptions.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryOptions.cs @@ -24,7 +24,7 @@ namespace DemaConsulting.SysML2Tools.Query; /// Immutable set of options parsed for one query command invocation. /// /// -/// Every field is shared across all 11 values so that a single +/// Every field is shared across all 12 values so that a single /// parsing pass can build one instance regardless of which verb /// was supplied; not every field is meaningful for every verb (see the per-field remarks /// below and the verb-grammar table in @@ -58,14 +58,24 @@ internal sealed record QueryOptions public string? Format { get; init; } /// - /// Gets the maximum traversal depth, supplied via --depth. + /// Gets the maximum impact-walk traversal depth, supplied via --walk-depth. /// /// /// Only meaningful for , where it bounds the transitive - /// impact walk. This reuses the same --depth flag as the render command - /// (diagram nesting depth); means unlimited. + /// impact walk; means unlimited. Command-scoped (parsed locally by + /// ); unrelated to the render command's own + /// --walk-depth flag. /// - public int? Depth { get; init; } + public int? WalkDepth { get; init; } + + /// + /// Gets the custom Markdown heading text, supplied via --heading. + /// + /// + /// Markdown output only; has no effect on --format json. + /// means the auto-generated "query {verb}[: {element}]" text is used instead. + /// + public string? Heading { get; init; } /// /// Gets the traversal direction, supplied via --direction. diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryResult.cs b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryResult.cs index 3a52e62d..f76b8c64 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryResult.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryResult.cs @@ -18,6 +18,8 @@ // 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.Query; /// @@ -26,7 +28,7 @@ namespace DemaConsulting.SysML2Tools.Query; /// /// /// A single shared shape (rather than one result type per verb) allows one non-duplicated -/// rendering layer to serve all 11 verbs: carries free-form narrative +/// rendering layer to serve all 12 verbs: carries free-form narrative /// lines (e.g. describe's kind/supertypes/annotations), and /// carries the tabular list of related elements every verb ultimately reports. /// @@ -86,4 +88,27 @@ internal sealed record QueryResultEntry /// verbs that need to attach more than one piece of extra context to a single entry. /// public IReadOnlyList Notes { get; init; } = []; + + /// + /// Gets the traversal direction of this entry relative to the queried element. Only + /// populated by the dependencies verb (which combines uses/used-by + /// results); for every other verb. Omitted from JSON output + /// entirely when , so this addition does not change the JSON + /// shape of any other verb. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public QueryEntryDirection? Direction { get; init; } +} + +/// +/// The traversal direction of a relative to the queried +/// element, populated only by the dependencies verb. +/// +internal enum QueryEntryDirection +{ + /// The entry is an element the queried element depends on (an outgoing reference). + Outgoing, + + /// The entry is an element that depends on the queried element (an incoming reference). + Incoming } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryResultRenderer.cs b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryResultRenderer.cs index d1f0026b..9d1d8a72 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryResultRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryResultRenderer.cs @@ -19,6 +19,7 @@ // SOFTWARE. using System.Text.Json; +using DemaConsulting.SysML2Tools.Utilities; namespace DemaConsulting.SysML2Tools.Query; @@ -26,7 +27,7 @@ namespace DemaConsulting.SysML2Tools.Query; /// Shared, non-duplicated rendering layer for : two pure methods /// converting the uniform result shape into Markdown lines or a JSON string. Every /// verb arm renders through this type instead of formatting its -/// own output, guaranteeing consistent, deterministically-ordered output across all 11 +/// own output, guaranteeing consistent, deterministically-ordered output across all 12 /// verbs. /// internal static class QueryResultRenderer @@ -34,17 +35,34 @@ internal static class QueryResultRenderer /// /// Renders a as Markdown lines: a heading, an optional summary /// bullet list, then a compact table of entries sorted by - /// (ordinal). + /// (ordinal). The dependencies verb + /// is the sole exception: after the heading, its entries are rendered as prose bullets + /// (see ) rather than a table. /// /// The result to render. + /// + /// The Markdown heading depth (number of leading # characters), sourced from the + /// global --depth flag (). Defaults to 1 (a + /// top-level # heading); expected to be pre-validated to the range 1-6 by the + /// caller (). + /// + /// + /// A custom heading text, supplied via query's own --heading flag, replacing + /// the auto-generated "query {verb}[: {element}]" text. Defaults to + /// , which uses the auto-generated text. + /// /// The Markdown output, one line per list entry. - public static IReadOnlyList RenderMarkdown(QueryResult result) + public static IReadOnlyList RenderMarkdown( + QueryResult result, int depth = 1, string? heading = null) { + var headingPrefix = new string('#', depth); + var headingText = heading ?? (result.Element is not null + ? $"query {result.Verb}: {result.Element}" + : $"query {result.Verb}"); + var lines = new List { - result.Element is not null - ? $"# query {result.Verb}: {result.Element}" - : $"# query {result.Verb}", + $"{headingPrefix} {headingText}", "" }; @@ -58,6 +76,22 @@ result.Element is not null lines.Add(""); } + if (result.Verb == "dependencies") + { + var sortedEntries = SortEntries(result.Entries); + var element = result.Element ?? ""; + + // Shorten every name in this result together - the subject element plus both + // directions' entries - so the subject sentence and every bullet agree on the same + // stripped prefix instead of each being shortened independently + var pool = new List { element }; + pool.AddRange(sortedEntries.Select(e => e.QualifiedName)); + var shortened = QualifiedNameShortener.Shorten(pool); + + lines.AddRange(RenderDependenciesBody(sortedEntries, element, shortened)); + return lines; + } + var sorted = SortEntries(result.Entries); if (sorted.Count == 0) { @@ -99,6 +133,72 @@ public static string RenderJson(QueryResult result) private static List SortEntries(IReadOnlyList entries) => [.. entries.OrderBy(e => e.QualifiedName, StringComparer.Ordinal)]; + /// + /// Renders the dependencies verb's body as prose bullets rather than a table: an + /// intro sentence plus one "Depends on" bullet per outgoing entry (or a single "has no + /// outgoing references" line when there are none), then symmetrically an intro sentence + /// plus one "Used by" bullet per incoming entry (or a single "No elements reference" + /// line when there are none). + /// + /// + /// The merged uses/used-by entries, already sorted by + /// (ordinal); filtering this list by + /// preserves that order within each group. + /// + /// The qualified name of the queried element. + /// + /// A map from each original qualified name (the queried element plus every entry's + /// ) to its + /// -shortened form, applied to the subject sentence and every bullet so the whole body + /// agrees on the same stripped common prefix. + /// + /// The Markdown lines for the dependencies body. + private static IReadOnlyList RenderDependenciesBody( + IReadOnlyList sortedEntries, + string element, + IReadOnlyDictionary shortened) + { + var outgoing = sortedEntries.Where(e => e.Direction == QueryEntryDirection.Outgoing).ToList(); + var incoming = sortedEntries.Where(e => e.Direction == QueryEntryDirection.Incoming).ToList(); + var shortElement = shortened.GetValueOrDefault(element, element); + + var lines = new List(); + + if (outgoing.Count == 0) + { + lines.Add($"{shortElement} has no outgoing references."); + } + else + { + lines.Add($"{shortElement} references the following elements:"); + lines.Add(""); + foreach (var entry in outgoing) + { + var shortName = shortened.GetValueOrDefault(entry.QualifiedName, entry.QualifiedName); + lines.Add($"- Depends on **{shortName}** ({entry.Kind})"); + } + } + + lines.Add(""); + + if (incoming.Count == 0) + { + lines.Add($"No elements reference {shortElement}."); + } + else + { + lines.Add($"The following elements reference {shortElement}:"); + lines.Add(""); + foreach (var entry in incoming) + { + var shortName = shortened.GetValueOrDefault(entry.QualifiedName, entry.QualifiedName); + lines.Add($"- Used by **{shortName}** ({entry.Kind})"); + } + } + + return lines; + } + /// /// Combines an entry's and /// into a single Markdown table cell. diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.cs b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.cs index 728a70f4..b4a0d6b4 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.cs @@ -50,6 +50,9 @@ internal static class QueryStrings /// Gets the 'used-by' verb summary line. public static string Query_VerbUsedBy => ResourceManager.GetString(nameof(Query_VerbUsedBy))!; + /// Gets the 'dependencies' verb summary line. + public static string Query_VerbDependencies => ResourceManager.GetString(nameof(Query_VerbDependencies))!; + /// Gets the 'impact' verb summary line. public static string Query_VerbImpact => ResourceManager.GetString(nameof(Query_VerbImpact))!; @@ -95,14 +98,23 @@ internal static class QueryStrings /// Gets the third line of the general --format option description. public static string Query_GeneralOptionFormat3 => ResourceManager.GetString(nameof(Query_GeneralOptionFormat3))!; + /// Gets the first line of the general --walk-depth option description. + public static string Query_GeneralOptionWalkDepth1 => ResourceManager.GetString(nameof(Query_GeneralOptionWalkDepth1))!; + + /// Gets the second line of the general --walk-depth option description. + public static string Query_GeneralOptionWalkDepth2 => ResourceManager.GetString(nameof(Query_GeneralOptionWalkDepth2))!; + /// Gets the first line of the general --depth option description. public static string Query_GeneralOptionDepth1 => ResourceManager.GetString(nameof(Query_GeneralOptionDepth1))!; /// Gets the second line of the general --depth option description. public static string Query_GeneralOptionDepth2 => ResourceManager.GetString(nameof(Query_GeneralOptionDepth2))!; - /// Gets the third line of the general --depth option description. - public static string Query_GeneralOptionDepth3 => ResourceManager.GetString(nameof(Query_GeneralOptionDepth3))!; + /// Gets the first line of the general --heading option description. + public static string Query_GeneralOptionHeading1 => ResourceManager.GetString(nameof(Query_GeneralOptionHeading1))!; + + /// Gets the second line of the general --heading option description. + public static string Query_GeneralOptionHeading2 => ResourceManager.GetString(nameof(Query_GeneralOptionHeading2))!; /// Gets the general --direction option line. public static string Query_GeneralOptionDirection => ResourceManager.GetString(nameof(Query_GeneralOptionDirection))!; @@ -128,6 +140,9 @@ internal static class QueryStrings /// Gets the fourth line of the "typical workflow" note. public static string Query_WorkflowNote4 => ResourceManager.GetString(nameof(Query_WorkflowNote4))!; + /// Gets the fifth line of the "typical workflow" note (a --depth/--heading example). + public static string Query_WorkflowNote5 => ResourceManager.GetString(nameof(Query_WorkflowNote5))!; + /// Gets the verb-specific usage format string used when the verb requires --element. public static string Query_VerbUsageWithElement => ResourceManager.GetString(nameof(Query_VerbUsageWithElement))!; @@ -137,8 +152,8 @@ internal static class QueryStrings /// Gets the required --element option line shown in verb-specific help. public static string Query_OptionElementRequired => ResourceManager.GetString(nameof(Query_OptionElementRequired))!; - /// Gets the --depth option line shown for the 'impact' verb. - public static string Query_OptionDepthImpact => ResourceManager.GetString(nameof(Query_OptionDepthImpact))!; + /// Gets the --walk-depth option line shown for the 'impact' verb. + public static string Query_OptionWalkDepthImpact => ResourceManager.GetString(nameof(Query_OptionWalkDepthImpact))!; /// Gets the --direction option line shown for the 'hierarchy' verb. public static string Query_OptionDirectionHierarchy => ResourceManager.GetString(nameof(Query_OptionDirectionHierarchy))!; @@ -152,6 +167,12 @@ internal static class QueryStrings /// Gets the --format option line shown in verb-specific help. public static string Query_OptionFormatVerb => ResourceManager.GetString(nameof(Query_OptionFormatVerb))!; + /// Gets the --depth option line shown in verb-specific help. + public static string Query_OptionDepthVerb => ResourceManager.GetString(nameof(Query_OptionDepthVerb))!; + + /// Gets the --heading option line shown in verb-specific help. + public static string Query_OptionHeadingVerb => ResourceManager.GetString(nameof(Query_OptionHeadingVerb))!; + /// Gets the --include-stdlib option line shown in verb-specific help. public static string Query_OptionIncludeStdlibVerb => ResourceManager.GetString(nameof(Query_OptionIncludeStdlibVerb))!; @@ -164,6 +185,9 @@ internal static class QueryStrings /// Gets the example invocation line for the 'used-by' verb. public static string Query_Example_UsedBy => ResourceManager.GetString(nameof(Query_Example_UsedBy))!; + /// Gets the example invocation line for the 'dependencies' verb. + public static string Query_Example_Dependencies => ResourceManager.GetString(nameof(Query_Example_Dependencies))!; + /// Gets the example invocation line for the 'impact' verb. public static string Query_Example_Impact => ResourceManager.GetString(nameof(Query_Example_Impact))!; @@ -197,10 +221,19 @@ internal static class QueryStrings /// Gets the JSON output-shape schema hint, shared by every verb. public static string Query_SchemaHint_Json => ResourceManager.GetString(nameof(Query_SchemaHint_Json))!; + /// Gets the Markdown output-shape schema hint specific to the 'dependencies' verb's bullet-prose rendering. + public static string Query_SchemaHint_Markdown_Dependencies => ResourceManager.GetString(nameof(Query_SchemaHint_Markdown_Dependencies))!; + + /// Gets the JSON output-shape schema hint specific to the 'dependencies' verb (includes the Direction field). + public static string Query_SchemaHint_Json_Dependencies => ResourceManager.GetString(nameof(Query_SchemaHint_Json_Dependencies))!; + + /// Gets the --walk-depth no-op note shown for the 'dependencies' verb. + public static string Query_OptionWalkDepthIgnoredDependencies => ResourceManager.GetString(nameof(Query_OptionWalkDepthIgnoredDependencies))!; + /// /// Gets the resx-sourced example invocation line for the given verb via the matching /// per-verb property above, so can add - /// example support with a single call site instead of an 11-arm switch of its own. + /// example support with a single call site instead of a 12-arm switch of its own. /// /// The verb to get the example invocation for. /// The example invocation line for . @@ -211,6 +244,7 @@ public static string GetExample(QueryVerb verb) { QueryVerb.Uses => Query_Example_Uses, QueryVerb.UsedBy => Query_Example_UsedBy, + QueryVerb.Dependencies => Query_Example_Dependencies, QueryVerb.Impact => Query_Example_Impact, QueryVerb.Describe => Query_Example_Describe, QueryVerb.Hierarchy => Query_Example_Hierarchy, diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.resx index 2ee180cf..23951de3 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.resx +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.resx @@ -70,6 +70,9 @@ used-by List the elements that use a given element + + dependencies Report an element's outgoing and incoming dependencies as prose + impact Report the transitive impact of a change to a given element @@ -115,14 +118,23 @@ --format (svg/png) + + --walk-depth <#> Maximum impact-walk depth ('impact' verb only); + + + default: unlimited + - --depth <#> Maximum impact-walk depth ('impact' verb only); note + --depth <#> Markdown heading depth (1-6, default: 1); Markdown - this is the same flag as the 'render' command's + output only, no effect on --format json - - diagram nesting --depth + + --heading <text> Custom Markdown heading text, replacing the default + + + '# query <verb>[: <element>]' text (Markdown only) --direction up|down|both Traversal direction ('hierarchy' verb only) @@ -143,11 +155,14 @@ workspace (e.g., 'query list --kind "part def"'), then pass those qualified names to - element-scoped verbs (uses, used-by, impact, describe, hierarchy, requirements, + element-scoped verbs (uses, used-by, dependencies, impact, describe, hierarchy, requirements, interface, connections, states) via --element. + + Example: 'query describe --element Pkg::Foo --depth 2 --heading "Foo Report" file.sysml' embeds the report as a '##' heading titled "Foo Report" instead of the default '# query describe: Pkg::Foo'. + Usage: sysml2tools query {0} --element <name> [options] <files...> @@ -157,8 +172,8 @@ --element <name>, -e <name> Qualified name of the target element (required) - - --depth <#> Maximum impact-walk depth (default: unlimited) + + --walk-depth <#> Maximum impact-walk depth (default: unlimited) --direction up|down|both Traversal direction (default: both) @@ -172,6 +187,12 @@ --format markdown|json Output format (default: markdown) + + --depth <#> Markdown heading depth (1-6, default: 1) + + + --heading <text> Custom Markdown heading text (default: auto-generated) + --include-stdlib Include OMG standard library elements in results @@ -184,8 +205,11 @@ sysml2tools query used-by --element VehicleDefinitions::Wheel test/SysMLModels/OMG/examples/VehicleExample/*.sysml + + sysml2tools query dependencies --element VehicleDefinitions::Vehicle test/SysMLModels/OMG/examples/VehicleExample/*.sysml + - sysml2tools query impact --element VehicleDefinitions::Axle --depth 2 test/SysMLModels/OMG/examples/VehicleExample/*.sysml + sysml2tools query impact --element VehicleDefinitions::Axle --walk-depth 2 test/SysMLModels/OMG/examples/VehicleExample/*.sysml sysml2tools query describe --element VehicleUsages::vehicle_C1 test/SysMLModels/OMG/examples/VehicleExample/*.sysml @@ -217,4 +241,13 @@ JSON output: { "Verb", "Element", "Summary": [...], "Entries": [{ "QualifiedName", "Kind", "Detail", "Notes": [] }] }, entries sorted by QualifiedName. + + Markdown output: a heading, then bullet-prose combining outgoing and incoming references ('- Depends on **Name** (kind)' / '- Used by **Name** (kind)'); qualified names are shortened by stripping their common prefix. No table is rendered for this verb. + + + JSON output: { "Verb", "Element", "Summary": [...], "Entries": [{ "QualifiedName", "Kind", "Detail", "Direction": "Outgoing"|"Incoming", "Notes": [] }] }, fully-qualified (unshortened); outgoing entries listed before incoming entries. + + + --walk-depth <#> Accepted but ignored (this verb reports direct references only, not bounded traversal) + diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryVerb.cs b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryVerb.cs index 96f3557a..9a794e56 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryVerb.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryVerb.cs @@ -21,7 +21,7 @@ namespace DemaConsulting.SysML2Tools.Query; /// -/// Identifies one of the eleven model-analysis operations supported by the +/// Identifies one of the twelve model-analysis operations supported by the /// query command. /// internal enum QueryVerb @@ -32,6 +32,12 @@ internal enum QueryVerb /// Lists the elements that use a given element (its incoming dependencies). UsedBy, + /// + /// Combines and for a given element into one + /// prose-rendered result: what it depends on, and what depends on it. + /// + Dependencies, + /// Reports the transitive set of elements affected by a change to a given element. Impact, @@ -74,6 +80,7 @@ internal static class QueryVerbParsing [ "uses", "used-by", + "dependencies", "impact", "describe", "hierarchy", @@ -101,6 +108,7 @@ public static QueryVerb Parse(string token) { "uses" => QueryVerb.Uses, "used-by" => QueryVerb.UsedBy, + "dependencies" => QueryVerb.Dependencies, "impact" => QueryVerb.Impact, "describe" => QueryVerb.Describe, "hierarchy" => QueryVerb.Hierarchy, @@ -128,6 +136,7 @@ public static string ToToken(QueryVerb verb) { QueryVerb.Uses => "uses", QueryVerb.UsedBy => "used-by", + QueryVerb.Dependencies => "dependencies", QueryVerb.Impact => "impact", QueryVerb.Describe => "describe", QueryVerb.Hierarchy => "hierarchy", diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderArgumentParser.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderArgumentParser.cs index a4ec74ea..6b694653 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderArgumentParser.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderArgumentParser.cs @@ -28,14 +28,16 @@ namespace DemaConsulting.SysML2Tools.Render; /// /// /// Recognizes only --output, --format, --view, --auto, -/// --view-type, --view-target, and --filter, plus positional file glob -/// patterns. Any other --prefixed token is rejected so that flags belonging to other -/// commands (e.g., --kind, --element) are never silently accepted. -/// --format's value is captured raw and validated later by +/// --view-type, --view-target, --filter, and --walk-depth, plus +/// positional file glob patterns. Any other --prefixed token is rejected so that flags +/// belonging to other commands (e.g., --kind, --element) are never silently +/// accepted. --format's value is captured raw and validated later by /// , matching the query command's validation style. /// --view-type, --view-target, and --filter are likewise captured raw /// here — mutual-exclusion and value validation happen entirely in -/// . +/// . --walk-depth (diagram nesting depth limit) is +/// a command-scoped flag, unrelated to the global --depth flag (Markdown heading depth, +/// used by --validate and query). /// internal static class RenderArgumentParser { @@ -60,6 +62,7 @@ public static RenderCommandOptions Parse(IReadOnlyList commandArgs) string? viewType = null; string? viewTarget = null; string? filterExpression = null; + int? walkDepth = null; var files = new List(); var index = 0; @@ -102,6 +105,11 @@ public static RenderCommandOptions Parse(IReadOnlyList commandArgs) arg, commandArgs, ref index, "a filter expression argument"); break; + case "--walk-depth": + walkDepth = CliArgumentHelpers.GetRequiredIntArgument( + arg, commandArgs, ref index, "a diagram nesting depth argument", 1); + break; + default: if (arg.StartsWith("-", StringComparison.Ordinal)) { @@ -123,6 +131,7 @@ public static RenderCommandOptions Parse(IReadOnlyList commandArgs) ViewType = viewType, ViewTarget = viewTarget, FilterExpression = filterExpression, + WalkDepth = walkDepth, Files = files }; } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs index e84cd784..26ac8efc 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs @@ -133,7 +133,7 @@ public static async Task RunAsync(Context context) // Render all views in the workspace (or the selected view when --view is specified) var diagramRenderer = new DiagramRenderer(); - var renderOptions = new RenderOptions(Themes.Light, DepthLimit: context.MaxRenderDepth ?? 0); + var renderOptions = new RenderOptions(Themes.Light, DepthLimit: options.WalkDepth ?? 0); var outputs = diagramRenderer.RenderWorkspace( loadResult.Workspace, renderer, renderOptions, viewFilter: effectiveViewFilter); @@ -332,8 +332,7 @@ public static void PrintHelp(Context context) context.WriteLine(RenderStrings.Render_OptionViewType); context.WriteLine(RenderStrings.Render_OptionViewTarget); context.WriteLine(RenderStrings.Render_OptionFilter); + context.WriteLine(RenderStrings.Render_OptionWalkDepth); context.WriteLine(""); - context.WriteLine(RenderStrings.Render_DepthNote1); - context.WriteLine(RenderStrings.Render_DepthNote2); } } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommandOptions.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommandOptions.cs index f491c3f8..ed7b98d3 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommandOptions.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommandOptions.cs @@ -105,4 +105,14 @@ internal sealed record RenderCommandOptions /// handled today). /// public string? FilterExpression { get; init; } + + /// + /// Gets the maximum diagram nesting depth limit, supplied via --walk-depth; + /// means unlimited. + /// + /// + /// Command-scoped (parsed locally by ); unrelated to the + /// query command's own --walk-depth flag (impact-walk depth). + /// + public int? WalkDepth { get; init; } } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.cs index 381ff324..b13d8ec9 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.cs @@ -68,9 +68,6 @@ internal static class RenderStrings /// Gets the --filter option line. public static string Render_OptionFilter => ResourceManager.GetString(nameof(Render_OptionFilter))!; - /// Gets the first line of the --depth cross-reference note. - public static string Render_DepthNote1 => ResourceManager.GetString(nameof(Render_DepthNote1))!; - - /// Gets the second line of the --depth cross-reference note. - public static string Render_DepthNote2 => ResourceManager.GetString(nameof(Render_DepthNote2))!; + /// Gets the --walk-depth option line. + public static string Render_OptionWalkDepth => ResourceManager.GetString(nameof(Render_OptionWalkDepth))!; } diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx index e5167a3a..b2505adb 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx @@ -88,10 +88,7 @@ --filter <expr> Filter expression for a dynamic view (requires --view-type and --view-target) - - The global --depth option also bounds diagram nesting depth for this command - - - (see 'sysml2tools --help'). + + --walk-depth <#> Limit rendered nesting depth; truncated parts show '+N more…' diff --git a/src/DemaConsulting.SysML2Tools.Tool/Utilities/QualifiedNameShortener.cs b/src/DemaConsulting.SysML2Tools.Tool/Utilities/QualifiedNameShortener.cs new file mode 100644 index 00000000..8f80c410 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Utilities/QualifiedNameShortener.cs @@ -0,0 +1,133 @@ +// 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.Utilities; + +/// +/// Reusable utility that strips the longest shared leading "::"-segment prefix from a set +/// of qualified names, so Markdown-oriented renderers can present names more compactly +/// without losing distinguishing information. +/// +/// +/// Deliberately verb/format-agnostic (holds no reference to any query/render type) so any +/// future renderer needing the same compaction can reuse it without new coupling. Stateless +/// and thread-safe; performs no I/O. +/// +internal static class QualifiedNameShortener +{ + /// + /// The "::" segment delimiter used by SysML qualified names. + /// + private const string SegmentSeparator = "::"; + + /// + /// Computes a shortened form of every supplied qualified name by stripping the longest + /// run of leading "::"-delimited segments common to ALL names in + /// , capped so every name always retains at least its + /// own final (leaf) segment. + /// + /// + /// The pool of qualified names to shorten together (e.g., a query subject's name plus + /// every related entry's name) - the common prefix is computed across the whole pool, + /// not per-name, so every returned mapping strips the same number of leading segments. + /// Must not be null; entries must not be null. May contain duplicates. + /// + /// + /// A dictionary mapping each distinct original name (ordinal key comparison) to its + /// shortened form. When has fewer than 2 distinct + /// names, or the names share no common leading segment, every value equals its key + /// unchanged. + /// + /// + /// Thrown when or any contained name is + /// . + /// + internal static IReadOnlyDictionary Shorten(IReadOnlyList qualifiedNames) + { + // Validate inputs - a null pool or a null entry cannot be split into segments + ArgumentNullException.ThrowIfNull(qualifiedNames); + foreach (var name in qualifiedNames) + { + ArgumentNullException.ThrowIfNull(name); + } + + // Reduce to the distinct names actually present - the common prefix (and the returned + // map's keys) only need to be computed once per distinct name, regardless of how many + // times it appears in the pool (e.g., the same dependency referenced by many entries) + var distinct = qualifiedNames.Distinct(StringComparer.Ordinal).ToList(); + + // Fewer than 2 distinct names means there is nothing to compare against, so stripping + // is skipped entirely per the "always retain distinguishing information" contract + if (distinct.Count < 2) + { + return distinct.ToDictionary(name => name, name => name, StringComparer.Ordinal); + } + + // Split every distinct name into its "::" segments once, reused by both the prefix + // computation and the final join + var segmentLists = distinct.Select(name => name.Split(SegmentSeparator)).ToList(); + + // Compute the common-prefix length, already capped so every name keeps its own leaf + var commonPrefixLength = ComputeCommonPrefixLength(segmentLists); + + // A zero-length common prefix means no stripping is possible; otherwise strip that many + // leading segments from every distinct name and rejoin the remainder + if (commonPrefixLength == 0) + { + return distinct.ToDictionary(name => name, name => name, StringComparer.Ordinal); + } + + var map = new Dictionary(distinct.Count, StringComparer.Ordinal); + for (var i = 0; i < distinct.Count; i++) + { + map[distinct[i]] = string.Join(SegmentSeparator, segmentLists[i][commonPrefixLength..]); + } + + return map; + } + + /// + /// Computes the length of the longest run of leading segments identical (ordinal) across + /// every entry of , capped so every name always retains + /// at least its own final (leaf) segment. + /// + /// + /// The "::"-split segments of every distinct qualified name in the pool; must contain at + /// least 2 entries. + /// + /// The common-prefix length, in segments (never negative). + private static int ComputeCommonPrefixLength(IReadOnlyList segmentLists) + { + // Cap the search at the shortest name's segment count minus 1, so the loop below can + // never strip a name down to nothing - every name always keeps its own leaf segment + var cap = segmentLists.Min(segments => segments.Length) - 1; + + // Walk segment indices up to the cap, stopping at the first index where not every name + // agrees on that segment's value; the count of indices that matched is the answer + var length = 0; + while (length < cap && segmentLists.All(segments => + string.Equals(segments[length], segmentLists[0][length], StringComparison.Ordinal))) + { + length++; + } + + return length; + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs index 8c919f4d..d757af04 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs @@ -318,32 +318,16 @@ public void Context_Create_DepthFlag_ZeroValue_ThrowsArgumentException() /// /// Test creating a context with --depth flag and value exceeding maximum (6) does not throw; - /// it sets MaxRenderDepth to the raw value and clamps HeadingDepth to 6. + /// it clamps HeadingDepth to 6. /// [Fact] - public void Context_Create_DepthFlag_ExceedsMaxValue_SetsMaxRenderDepth() + public void Context_Create_DepthFlag_ExceedsMaxValue_ClampsHeadingDepth() { // Act: execute the operation being tested using var context = Context.Create(["--depth", "7"]); - // Assert: HeadingDepth is clamped to 6; MaxRenderDepth preserves the raw value + // Assert: HeadingDepth is clamped to 6 Assert.Equal(6, context.HeadingDepth); - Assert.Equal(7, context.MaxRenderDepth); - Assert.Equal(0, context.ExitCode); - } - - /// - /// Test creating a context with --depth 3 sets both HeadingDepth and MaxRenderDepth. - /// - [Fact] - public void Context_Create_DepthFlag_SetsMaxRenderDepth() - { - // Act: execute the operation being tested - using var context = Context.Create(["--depth", "3"]); - - // Assert: both properties reflect the supplied depth - Assert.Equal(3, context.HeadingDepth); - Assert.Equal(3, context.MaxRenderDepth); Assert.Equal(0, context.ExitCode); } @@ -755,12 +739,13 @@ public void Context_Create_ExportCommand_OutputWithoutValue_ThrowsArgumentExcept } /// - /// Test creating a context with the query command and each of the 11 verb tokens sets + /// Test creating a context with the query command and each of the 12 verb tokens sets /// Command to SysmlCommand.Query and Query.Verb to the matching enum value. /// [Theory] [InlineData("uses")] [InlineData("used-by")] + [InlineData("dependencies")] [InlineData("impact")] [InlineData("describe")] [InlineData("hierarchy")] @@ -926,19 +911,48 @@ public void Context_Create_QueryCommand_WithFormatJson_SetsQueryFormat() } /// - /// Test creating a context with the query command and --depth sets Query.Depth without - /// disturbing MaxRenderDepth's meaning for render. + /// Test creating a context with the query command and --walk-depth sets Query.WalkDepth + /// without affecting the global --depth/HeadingDepth flow. + /// + [Fact] + public void Context_Create_QueryCommand_WithWalkDepthFlag_SetsQueryWalkDepth() + { + // Act: execute the operation being tested + using var context = Context.Create(["query", "impact", "--element", "Pkg::Foo", "--walk-depth", "3"]); + + // Assert: verify expected behavior + Assert.NotNull(context.Query); + Assert.Equal(3, context.Query.WalkDepth); + Assert.Equal(1, context.HeadingDepth); + } + + /// + /// Test creating a context with the query command and no --heading leaves the property + /// null (default behavior unchanged). + /// + [Fact] + public void Context_Create_QueryCommand_WithoutHeading_LeavesHeadingNull() + { + // Act: execute the operation being tested + using var context = Context.Create(["query", "list"]); + + // Assert: verify expected behavior + Assert.NotNull(context.Query); + Assert.Null(context.Query.Heading); + } + + /// + /// Test creating a context with the query command and --heading sets Query.Heading. /// [Fact] - public void Context_Create_QueryCommand_WithDepthFlag_SetsQueryDepth() + public void Context_Create_QueryCommand_WithHeadingFlag_SetsHeading() { // Act: execute the operation being tested - using var context = Context.Create(["query", "impact", "--element", "Pkg::Foo", "--depth", "3"]); + using var context = Context.Create(["query", "list", "--heading", "Custom Heading"]); // Assert: verify expected behavior Assert.NotNull(context.Query); - Assert.Equal(3, context.Query.Depth); - Assert.Equal(3, context.MaxRenderDepth); + Assert.Equal("Custom Heading", context.Query.Heading); } /// diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Help/HelpArgumentParserTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Help/HelpArgumentParserTests.cs index b18f7191..ffef5a2e 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Help/HelpArgumentParserTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Help/HelpArgumentParserTests.cs @@ -36,6 +36,7 @@ public class HelpArgumentParserTests [ "uses", "used-by", + "dependencies", "impact", "describe", "hierarchy", @@ -105,7 +106,7 @@ public void HelpArgumentParser_Parse_QueryNoVerb_SetsTargetCommandQueryOnly() /// /// 'help query <verb>' sets both TargetCommand and TargetVerb for every one of the - /// 11 recognized verbs. + /// 12 recognized verbs. /// [Theory] [MemberData(nameof(QueryVerbTokens))] diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Help/HelpSubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Help/HelpSubsystemTests.cs index 8dc44519..2a98b683 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Help/HelpSubsystemTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Help/HelpSubsystemTests.cs @@ -25,7 +25,7 @@ namespace DemaConsulting.SysML2Tools.Tests.Help; /// /// End-to-end subsystem tests for the help command, exercised via /// + . Verifies bare help, -/// per-command help, per-verb help for all 11 query verbs, unknown-command/verb +/// per-command help, per-verb help for all 12 query verbs, unknown-command/verb /// handling, parity between help <command> and <command> --help, and /// the --silent interaction. /// @@ -40,6 +40,7 @@ public class HelpSubsystemTests [ "uses", "used-by", + "dependencies", "impact", "describe", "hierarchy", @@ -55,6 +56,7 @@ public class HelpSubsystemTests [ "uses", "used-by", + "dependencies", "impact", "describe", "hierarchy", @@ -144,7 +146,7 @@ public async Task HelpSubsystem_HelpRender_MatchesRenderHelpFlag() } /// - /// 'help query' (no verb) produces query-distinguishing content — an overview of all 11 + /// 'help query' (no verb) produces query-distinguishing content — an overview of all 12 /// verbs — and matches 'query --help'. /// [Fact] @@ -162,7 +164,7 @@ public async Task HelpSubsystem_HelpQuery_MatchesQueryHelpFlag() } /// - /// 'help query <verb>' matches 'query <verb> --help' for every one of the 11 + /// 'help query <verb>' matches 'query <verb> --help' for every one of the 12 /// verbs, and mentions that verb's real flags where applicable. /// [Theory] @@ -179,7 +181,7 @@ public async Task HelpSubsystem_HelpQueryVerb_MatchesQueryVerbHelpFlag(string ve switch (verb) { case "impact": - Assert.Contains("--depth", helpOutput); + Assert.Contains("--walk-depth", helpOutput); break; case "hierarchy": diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryErrorPathTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryErrorPathTests.cs index 466c35f3..95b289fc 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryErrorPathTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryErrorPathTests.cs @@ -75,6 +75,18 @@ public async Task Query_UnsupportedFormat_ThrowsArgumentException() Assert.Contains("xml", exception.Message); } + /// + /// A non-integer --walk-depth value throws ArgumentException naming the flag, mirroring + /// the existing --format validation style. + /// + [Fact] + public void Query_WalkDepthInvalidValue_ThrowsArgumentException() + { + var exception = Assert.Throws( + () => Context.Create(["query", "impact", "--element", "Pkg::Foo", "--walk-depth", "abc", "file.sysml"])); + Assert.Contains("--walk-depth", exception.Message); + } + /// /// A file with a syntax error still loads best-effort (matching 'lint'/'render'): parse /// diagnostics are reported, and 'list' still runs against whatever declarations were diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryRenderingTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryRenderingTests.cs index 7b5b9e6b..ef06f4f4 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryRenderingTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryRenderingTests.cs @@ -45,6 +45,107 @@ public void RenderMarkdown_NoEntries_ReportsNoEntries() Assert.Contains(lines, l => l.Contains("No entries")); } + /// + /// RenderMarkdown for the 'dependencies' verb renders its entries as prose bullets + /// (never a table), sorted by qualified name (ordinal) within each direction + /// regardless of input order, and shortens every name (subject + entries) by the + /// longest shared leading "::"-segment prefix across the whole pool. + /// + [Fact] + public void RenderMarkdown_DependenciesVerb_RendersBulletProseNotTable() + { + var result = new QueryResult + { + Verb = "dependencies", + Element = "Model::Car", + Entries = + [ + new QueryResultEntry { QualifiedName = "Model::Zebra", Kind = "supertype", Direction = QueryEntryDirection.Outgoing }, + new QueryResultEntry { QualifiedName = "Model::Apple", Kind = "supertype", Direction = QueryEntryDirection.Outgoing }, + new QueryResultEntry { QualifiedName = "Model::Truck", Kind = "supertype", Direction = QueryEntryDirection.Incoming } + ] + }; + + var lines = QueryResultRenderer.RenderMarkdown(result); + + Assert.DoesNotContain(lines, l => l.Contains("| Qualified Name | Kind | Detail |")); + Assert.Contains("Car references the following elements:", lines); + Assert.Contains("- Depends on **Apple** (supertype)", lines); + Assert.Contains("- Depends on **Zebra** (supertype)", lines); + Assert.Contains("The following elements reference Car:", lines); + Assert.Contains("- Used by **Truck** (supertype)", lines); + + // The outgoing bullets must appear in ordinal order (Apple before Zebra) + var lineList = lines.ToList(); + var appleIndex = lineList.IndexOf("- Depends on **Apple** (supertype)"); + var zebraIndex = lineList.IndexOf("- Depends on **Zebra** (supertype)"); + Assert.True(appleIndex >= 0 && zebraIndex >= 0 && appleIndex < zebraIndex); + } + + /// + /// RenderMarkdown for the 'dependencies' verb leaves names fully-qualified when the + /// subject and its entries share no common leading segment. + /// + [Fact] + public void RenderMarkdown_DependenciesVerb_NoCommonPrefix_LeavesNamesFullyQualified() + { + var result = new QueryResult + { + Verb = "dependencies", + Element = "PkgA::Car", + Entries = + [ + new QueryResultEntry { QualifiedName = "PkgB::Vehicle", Kind = "supertype", Direction = QueryEntryDirection.Outgoing } + ] + }; + + var lines = QueryResultRenderer.RenderMarkdown(result); + + Assert.Contains("PkgA::Car references the following elements:", lines); + Assert.Contains("- Depends on **PkgB::Vehicle** (supertype)", lines); + } + + /// + /// RenderMarkdown for the 'dependencies' verb reports both "has no outgoing + /// references"/"No elements reference" prose lines (and no bullets or "_No entries._" + /// fallback) when neither direction has any entries. + /// + [Fact] + public void RenderMarkdown_DependenciesVerb_EmptyOutgoingAndIncoming_ReportsBothProseLines() + { + var result = new QueryResult { Verb = "dependencies", Element = "Model::Car" }; + + var lines = QueryResultRenderer.RenderMarkdown(result); + + Assert.Contains("Model::Car has no outgoing references.", lines); + Assert.Contains("No elements reference Model::Car.", lines); + Assert.DoesNotContain(lines, l => l.Contains("Depends on")); + Assert.DoesNotContain(lines, l => l.Contains("Used by")); + Assert.DoesNotContain(lines, l => l.Contains("_No entries._")); + } + + /// + /// 'dependencies' end-to-end: '--depth'/'--heading' apply to its heading line exactly + /// like every other verb, while the bullet-prose body below is unaffected. + /// + [Fact] + public async Task Dependencies_DepthAndHeadingOptions_ApplyToHeadingLikeOtherVerbs() + { + const string sysml = """ + package Model { + part def Vehicle; + part def Car specializes Vehicle; + } + """; + + var (output, exitCode) = await QueryTestFixtures.RunQueryAsync( + sysml, "dependencies", "--element", "Model::Car", "--depth", "3", "--heading", "Custom"); + + Assert.Equal(0, exitCode); + Assert.Contains("### Custom", output); + Assert.Contains("Depends on **Vehicle** (supertype)", output); + } + /// /// RenderMarkdown sorts entries by qualified name (ordinal), regardless of input order. /// @@ -97,6 +198,62 @@ public void RenderMarkdown_EntryWithDetailAndNotes_CombinesIntoOneCell() Assert.Contains(lines, l => l.Contains("depth 1; extra note")); } + /// + /// RenderMarkdown with default arguments (no depth/heading override) produces the + /// same single '#' heading as before this feature was added. + /// + [Fact] + public void RenderMarkdown_DefaultArguments_ProducesUnchangedTopLevelHeading() + { + var result = new QueryResult { Verb = "uses", Element = "Model::Foo" }; + + var lines = QueryResultRenderer.RenderMarkdown(result); + + Assert.Equal("# query uses: Model::Foo", lines[0]); + } + + /// + /// RenderMarkdown with a custom depth uses that many '#' characters for the + /// heading, keeping the auto-generated heading text. + /// + [Fact] + public void RenderMarkdown_CustomDepth_UsesThatManyHeadingHashes() + { + var result = new QueryResult { Verb = "uses", Element = "Model::Foo" }; + + var lines = QueryResultRenderer.RenderMarkdown(result, depth: 3); + + Assert.Equal("### query uses: Model::Foo", lines[0]); + } + + /// + /// RenderMarkdown with a custom heading replaces the auto-generated heading text + /// entirely, with no merging of verb/element information. + /// + [Fact] + public void RenderMarkdown_CustomHeading_ReplacesAutoGeneratedText() + { + var result = new QueryResult { Verb = "uses", Element = "Model::Foo" }; + + var lines = QueryResultRenderer.RenderMarkdown(result, heading: "Custom Heading"); + + Assert.Equal("# Custom Heading", lines[0]); + } + + /// + /// RenderMarkdown with both a custom depth and heading combines both + /// overrides: the requested number of '#' characters and the custom heading text. + /// + [Fact] + public void RenderMarkdown_CustomDepthAndHeading_CombinesBothOverrides() + { + var result = new QueryResult { Verb = "uses", Element = "Model::Foo" }; + + var lines = QueryResultRenderer.RenderMarkdown(result, depth: 5, heading: "Custom Heading"); + + Assert.Equal("##### Custom Heading", lines[0]); + } + /// /// RenderJson round-trips through System.Text.Json and preserves the verb, element, and /// entries. @@ -147,6 +304,91 @@ public void RenderJson_UnorderedEntries_SortsByQualifiedNameOrdinal() Assert.Equal("Model::Zebra", deserialized.Entries[1].QualifiedName); } + /// + /// RenderJson includes a populated 'Direction' field for each entry of a 'dependencies' + /// result, round-tripping 'Outgoing'/'Incoming' correctly. + /// + [Fact] + public void RenderJson_DependenciesVerb_IncludesDirectionField() + { + var result = new QueryResult + { + Verb = "dependencies", + Element = "Model::Car", + Entries = + [ + new QueryResultEntry { QualifiedName = "Model::Vehicle", Kind = "supertype", Direction = QueryEntryDirection.Outgoing }, + new QueryResultEntry { QualifiedName = "Model::Truck", Kind = "supertype", Direction = QueryEntryDirection.Incoming } + ] + }; + + var json = QueryResultRenderer.RenderJson(result); + var deserialized = JsonSerializer.Deserialize(json, QueryResultSerializerContext.Default.QueryResult); + + Assert.NotNull(deserialized); + Assert.Contains("\"Direction\"", json); + Assert.Equal(QueryEntryDirection.Incoming, deserialized!.Entries[0].Direction); + Assert.Equal(QueryEntryDirection.Outgoing, deserialized.Entries[1].Direction); + } + + /// + /// Regression test: 'dependencies' JSON output remains fully-qualified (unaffected by + /// the Markdown-only shortening), even + /// when the subject and entries share a common leading segment that Markdown would + /// shorten. + /// + [Fact] + public void RenderJson_DependenciesVerb_NamesRemainFullyQualified() + { + var result = new QueryResult + { + Verb = "dependencies", + Element = "Model::Car", + Entries = + [ + new QueryResultEntry { QualifiedName = "Model::Vehicle", Kind = "supertype", Direction = QueryEntryDirection.Outgoing }, + new QueryResultEntry { QualifiedName = "Model::Truck", Kind = "supertype", Direction = QueryEntryDirection.Incoming } + ] + }; + + // Markdown shortens the shared "Model" prefix away... + var markdown = QueryResultRenderer.RenderMarkdown(result); + Assert.Contains("Car references the following elements:", markdown); + Assert.DoesNotContain(markdown, l => l.Contains("Model::Vehicle")); + + // ...but JSON output for the very same result stays fully-qualified throughout + var json = QueryResultRenderer.RenderJson(result); + var deserialized = JsonSerializer.Deserialize(json, QueryResultSerializerContext.Default.QueryResult); + + Assert.NotNull(deserialized); + Assert.Equal("Model::Car", deserialized!.Element); + Assert.Contains(deserialized.Entries, e => e.QualifiedName == "Model::Vehicle"); + Assert.Contains(deserialized.Entries, e => e.QualifiedName == "Model::Truck"); + Assert.Contains("Model::Vehicle", json); + Assert.Contains("Model::Truck", json); + Assert.Contains("Model::Car", json); + } + + /// + /// Regression test: for every verb other than 'dependencies', the 'Direction' field is + /// omitted from JSON output entirely (not serialized as 'null'), so adding it does not + /// change any other verb's JSON output shape. + /// + [Fact] + public void RenderJson_NonDependenciesVerb_DirectionFieldOmittedFromOutput() + { + var result = new QueryResult + { + Verb = "uses", + Element = "Model::Foo", + Entries = [new QueryResultEntry { QualifiedName = "Model::Bar", Kind = "supertype" }] + }; + + var json = QueryResultRenderer.RenderJson(result); + + Assert.DoesNotContain("Direction", json); + } + /// /// End-to-end: '--format markdown' and '--format json' for the same 'uses' query report /// the same qualified names, in the same order. diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QuerySubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QuerySubsystemTests.cs index 3c232890..0826ed5b 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QuerySubsystemTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QuerySubsystemTests.cs @@ -74,6 +74,7 @@ state def Light { { { "uses", "Model::Mid" }, { "used-by", "Model::Root" }, + { "dependencies", "Model::Mid" }, { "impact", "Model::Root" }, { "describe", "Model::Mid" }, { "hierarchy", "Model::Mid" }, @@ -86,7 +87,7 @@ state def Light { }; /// - /// Each of the 11 verbs, given a valid element (where required) and a loadable workspace, + /// Each of the 12 verbs, given a valid element (where required) and a loadable workspace, /// dispatches to real QueryEngine logic and produces exit code 0. /// [Theory] @@ -120,6 +121,7 @@ public async Task QuerySubsystem_AnyVerb_WithValidInput_DispatchesToRealLogic(st [Theory] [InlineData("uses")] [InlineData("used-by")] + [InlineData("dependencies")] [InlineData("impact")] [InlineData("describe")] [InlineData("hierarchy")] @@ -219,6 +221,23 @@ public async Task QuerySubsystem_FormatJson_DispatchesWithoutError() Assert.Contains("\"Verb\": \"list\"", output); } + /// + /// '--format json' output is byte-identical whether or not '--depth'/'--heading' are + /// supplied, since those options only affect Markdown rendering. + /// + [Fact] + public async Task QuerySubsystem_FormatJson_UnaffectedByDepthOrHeading() + { + var (withoutFlags, exitCodeWithout) = await QueryTestFixtures.RunQueryAsync( + Fixture, "list", "--format", "json"); + var (withFlags, exitCodeWith) = await QueryTestFixtures.RunQueryAsync( + Fixture, "list", "--format", "json", "--depth", "5", "--heading", "Custom Heading"); + + Assert.Equal(0, exitCodeWithout); + Assert.Equal(0, exitCodeWith); + Assert.Equal(withoutFlags, withFlags); + } + /// /// Regression test for the glob-expansion bug fix: a glob pattern such as '*.sysml' /// (previously treated as a literal, never-matching file name) now resolves to every @@ -339,7 +358,8 @@ public async Task QuerySubsystem_QueryHelp_NoVerb_MentionsTypicalWorkflow() [Theory] [InlineData("uses", "query uses --element VehicleDefinitions::Vehicle")] [InlineData("used-by", "query used-by --element VehicleDefinitions::Wheel")] - [InlineData("impact", "query impact --element VehicleDefinitions::Axle --depth 2")] + [InlineData("dependencies", "query dependencies --element VehicleDefinitions::Vehicle")] + [InlineData("impact", "query impact --element VehicleDefinitions::Axle --walk-depth 2")] [InlineData("describe", "query describe --element VehicleUsages::vehicle_C1")] [InlineData("hierarchy", "query hierarchy --element VehicleUsages::vehicle_C3 --direction up")] [InlineData("requirements", "query requirements --element VehicleUsages::vehicle_C1")] @@ -365,8 +385,16 @@ public async Task QuerySubsystem_QueryVerbHelp_MentionsExampleInvocationAndSchem // Assert var output = outWriter.ToString(); Assert.Contains(expectedExampleSubstring, output); - Assert.Contains("Qualified Name", output); - Assert.Contains("QualifiedName", output); + if (verbToken == "dependencies") + { + Assert.Contains("Depends on", output); + Assert.Contains("Direction", output); + } + else + { + Assert.Contains("Qualified Name", output); + Assert.Contains("QualifiedName", output); + } } finally { diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs index 4db004af..83587a70 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs @@ -21,7 +21,7 @@ namespace DemaConsulting.SysML2Tools.Tests.Query; /// -/// Primary integration suite for the 11 real query verb implementations: each test +/// Primary integration suite for the 12 real query verb implementations: each test /// builds a small, fully-controlled inline SysML fixture and asserts on the resulting /// Markdown output produced end-to-end through . /// @@ -76,7 +76,97 @@ package Model { } /// - /// 'impact' with --depth 1 only reaches direct incoming references, not their own + /// 'dependencies' combines 'uses' (outgoing) and 'used-by' (incoming) for one element + /// into a single prose result: a "Depends on" bullet for each outgoing reference and a + /// "Used by" bullet for each incoming reference. + /// + [Fact] + public async Task Dependencies_CombinesOutgoingAndIncoming_ReportsBothDirections() + { + const string sysml = """ + package Model { + part def Vehicle; + part def Car specializes Vehicle; + part def Truck specializes Car; + } + """; + + var (output, exitCode) = await QueryTestFixtures.RunQueryAsync( + sysml, "dependencies", "--element", "Model::Car"); + + Assert.Equal(0, exitCode); + Assert.Contains("Depends on **Vehicle** (supertype)", output); + Assert.Contains("Used by **Truck** (supertype)", output); + } + + /// + /// 'dependencies' reports a single prose line (instead of a bullet list) when the + /// target element has no outgoing references. + /// + [Fact] + public async Task Dependencies_NoOutgoingReferences_ReportsProseLineInsteadOfBulletList() + { + const string sysml = """ + package Model { + part def Vehicle; + part def Car specializes Vehicle; + } + """; + + var (output, exitCode) = await QueryTestFixtures.RunQueryAsync( + sysml, "dependencies", "--element", "Model::Vehicle"); + + Assert.Equal(0, exitCode); + Assert.Contains("Vehicle has no outgoing references.", output); + Assert.DoesNotContain("Depends on", output); + } + + /// + /// 'dependencies' reports a single prose line (instead of a bullet list) when no other + /// element references the target element. + /// + [Fact] + public async Task Dependencies_NoIncomingReferences_ReportsProseLineInsteadOfBulletList() + { + const string sysml = """ + package Model { + part def Vehicle; + part def Car specializes Vehicle; + } + """; + + var (output, exitCode) = await QueryTestFixtures.RunQueryAsync( + sysml, "dependencies", "--element", "Model::Car"); + + Assert.Equal(0, exitCode); + Assert.Contains("No elements reference Car.", output); + Assert.DoesNotContain("Used by", output); + } + + /// + /// 'dependencies' renders its body as prose bullets, never a Markdown table, unlike + /// every other verb. + /// + [Fact] + public async Task Dependencies_MarkdownOutput_ContainsNoTable() + { + const string sysml = """ + package Model { + part def Vehicle; + part def Car specializes Vehicle; + part def Truck specializes Vehicle; + } + """; + + var (output, exitCode) = await QueryTestFixtures.RunQueryAsync( + sysml, "dependencies", "--element", "Model::Car"); + + Assert.Equal(0, exitCode); + Assert.DoesNotContain("| Qualified Name | Kind | Detail |", output); + } + + /// + /// 'impact' with --walk-depth 1 only reaches direct incoming references, not their own /// incoming references. /// [Fact] @@ -91,7 +181,7 @@ package Model { """; var (output, exitCode) = await QueryTestFixtures.RunQueryAsync( - sysml, "impact", "--element", "Model::Root", "--depth", "1"); + sysml, "impact", "--element", "Model::Root", "--walk-depth", "1"); Assert.Equal(0, exitCode); Assert.Contains("Model::Mid", output); @@ -99,7 +189,7 @@ package Model { } /// - /// 'impact' with no --depth reaches the full transitive closure. + /// 'impact' with no --walk-depth reaches the full transitive closure. /// [Fact] public async Task Impact_Unbounded_ReachesTransitiveClosure() diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs index 0d8c3821..8af0dfd6 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs @@ -280,11 +280,11 @@ await File.WriteAllTextAsync( } /// - /// RenderCommand renders with --depth 1 and the SVG output contains an ellipsis + /// RenderCommand renders with --walk-depth 1 and the SVG output contains an ellipsis /// character indicating that children were truncated at depth limit. /// [Fact] - public async Task RenderSubsystem_WithDepth_LimitsNesting() + public async Task RenderSubsystem_WithWalkDepth_LimitsNesting() { // Arrange: write a SysML model with a view and part defs; create temp output dir var tempDir = Path.Combine(Path.GetTempPath(), $"render_depth_{Guid.NewGuid():N}"); @@ -300,9 +300,9 @@ public async Task RenderSubsystem_WithDepth_LimitsNesting() using var outWriter = new StringWriter(); Console.SetOut(outWriter); - // Act: render with depth=1 to trigger the ellipsis truncation + // Act: render with walk-depth=1 to trigger the ellipsis truncation using var context = Context.Create( - ["render", "--format", "svg", "--depth", "1", "--output", outputDir, tempFile]); + ["render", "--format", "svg", "--walk-depth", "1", "--output", outputDir, tempFile]); await Program.RunAsync(context); // Assert: SVG output exists and contains the ellipsis marker diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs index fc37ed51..012ccbb4 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Resources/ResxResourceTests.cs @@ -39,7 +39,7 @@ namespace DemaConsulting.SysML2Tools.Tests.Resources; /// cannot silently drift apart. /// /// -/// The eleven query example-invocation keys (Query_Example_*) are additionally +/// The twelve query example-invocation keys (Query_Example_*) are additionally /// exposed through QueryStrings.GetExample(QueryVerb), but they still each have a /// dedicated public static string property (see QueryStrings.cs), so the /// bidirectional parity check below requires no special-casing for them. diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Utilities/QualifiedNameShortenerTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Utilities/QualifiedNameShortenerTests.cs new file mode 100644 index 00000000..f3c9da7e --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Utilities/QualifiedNameShortenerTests.cs @@ -0,0 +1,188 @@ +// 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.Utilities; + +namespace DemaConsulting.SysML2Tools.Tests; + +/// +/// Tests for the QualifiedNameShortener class. +/// +[Collection("Sequential")] +public class QualifiedNameShortenerTests +{ + /// + /// Test that Shorten strips the single shared leading segment across a pool of names, + /// retaining each name's remaining segments. + /// + [Fact] + public void QualifiedNameShortener_Shorten_OneSharedLeadingSegment_StripsThatSegment() + { + // Arrange: three names sharing the single leading segment "A" + string[] names = ["A::B::x", "A::B::y", "A::T::g"]; + + // Act: execute the operation being tested + var result = QualifiedNameShortener.Shorten(names); + + // Assert: the shared "A" segment is stripped from every name + Assert.Equal("B::x", result["A::B::x"]); + Assert.Equal("B::y", result["A::B::y"]); + Assert.Equal("T::g", result["A::T::g"]); + } + + /// + /// Test that Shorten leaves every name unchanged when the pool shares no common leading + /// segment (different top-level packages). + /// + [Fact] + public void QualifiedNameShortener_Shorten_NoCommonPrefix_LeavesNamesUnchanged() + { + // Arrange: names rooted in different top-level packages + string[] names = ["A::B::x", "C::D::y"]; + + // Act: execute the operation being tested + var result = QualifiedNameShortener.Shorten(names); + + // Assert: no stripping occurred + Assert.Equal("A::B::x", result["A::B::x"]); + Assert.Equal("C::D::y", result["C::D::y"]); + } + + /// + /// Test that Shorten leaves a single-name pool unchanged, since there is nothing to + /// compare it against. + /// + [Fact] + public void QualifiedNameShortener_Shorten_SingleNamePool_LeavesNameUnchanged() + { + // Arrange: a pool containing only one distinct name + string[] names = ["A::B::x"]; + + // Act: execute the operation being tested + var result = QualifiedNameShortener.Shorten(names); + + // Assert: the name is returned unchanged + Assert.Single(result); + Assert.Equal("A::B::x", result["A::B::x"]); + } + + /// + /// Test that Shorten never strips a name down to an empty string: when every name in + /// the pool is identical, only one distinct name remains, which - per the "fewer than 2 + /// distinct names" edge case - is returned unchanged (trivially retaining its leaf). + /// + [Fact] + public void QualifiedNameShortener_Shorten_AllIdenticalNames_KeepsLeafSegment() + { + // Arrange: a pool of duplicate entries for the same name (e.g., referenced by multiple + // entries in a dependencies result) + string[] names = ["A::B::x", "A::B::x", "A::B::x"]; + + // Act: execute the operation being tested + var result = QualifiedNameShortener.Shorten(names); + + // Assert: only one distinct name remains, unchanged, so its leaf segment "x" is never + // stripped away to an empty string + Assert.Single(result); + Assert.Equal("A::B::x", result["A::B::x"]); + Assert.EndsWith("x", result["A::B::x"], StringComparison.Ordinal); + } + + /// + /// Test that Shorten strips every shared leading segment when the pool shares a deeper + /// (2+ segment) common prefix. + /// + [Fact] + public void QualifiedNameShortener_Shorten_DeeperCommonPrefix_StripsAllSharedSegments() + { + // Arrange: names sharing the two leading segments "A::B" + string[] names = ["A::B::C::x", "A::B::C::y", "A::B::D::z"]; + + // Act: execute the operation being tested + var result = QualifiedNameShortener.Shorten(names); + + // Assert: both shared segments "A::B" are stripped from every name + Assert.Equal("C::x", result["A::B::C::x"]); + Assert.Equal("C::y", result["A::B::C::y"]); + Assert.Equal("D::z", result["A::B::D::z"]); + } + + /// + /// Test that Shorten caps the common-prefix length at the shortest name's segment count + /// minus 1, so a short name is never stripped down to nothing even when a longer name in + /// the pool shares more leading segments than the short name has to spare. + /// + [Fact] + public void QualifiedNameShortener_Shorten_ShortestNameBoundsCap_RetainsShortestNamesLeaf() + { + // Arrange: "A::B" has only 2 segments (cap = 1), even though "A::B::C" shares the + // 2-segment prefix "A::B" with it + string[] names = ["A::B", "A::B::C"]; + + // Act: execute the operation being tested + var result = QualifiedNameShortener.Shorten(names); + + // Assert: only 1 segment ("A") is stripped, not 2, so "A::B" is never reduced to "" + Assert.Equal("B", result["A::B"]); + Assert.Equal("B::C", result["A::B::C"]); + } + + /// + /// Test that Shorten throws ArgumentNullException when the pool itself is null. + /// + [Fact] + public void QualifiedNameShortener_Shorten_NullPool_ThrowsArgumentNullException() + { + // Arrange & Act & Assert: null pool throws ArgumentNullException + Assert.Throws(() => QualifiedNameShortener.Shorten(null!)); + } + + /// + /// Test that Shorten throws ArgumentNullException when the pool contains a null name. + /// + [Fact] + public void QualifiedNameShortener_Shorten_NullEntryInPool_ThrowsArgumentNullException() + { + // Arrange: a pool containing a null entry + List names = ["A::B::x", null!]; + + // Act & Assert: a null entry throws ArgumentNullException + Assert.Throws(() => QualifiedNameShortener.Shorten(names)); + } + + /// + /// Test that Shorten deduplicates the pool, so a name repeated many times produces only + /// one entry in the returned map. + /// + [Fact] + public void QualifiedNameShortener_Shorten_DuplicateNamesInPool_ReturnsOneEntryPerDistinctName() + { + // Arrange: a pool where "A::B::x" is repeated + string[] names = ["A::B::x", "A::B::x", "A::T::g"]; + + // Act: execute the operation being tested + var result = QualifiedNameShortener.Shorten(names); + + // Assert: exactly two distinct entries are returned, both shortened + Assert.Equal(2, result.Count); + Assert.Equal("B::x", result["A::B::x"]); + Assert.Equal("T::g", result["A::T::g"]); + } +}