diff --git a/docs/design/sysml2-tools-core/query.md b/docs/design/sysml2-tools-core/query.md
index 5d7a6637..68d4c53c 100644
--- a/docs/design/sysml2-tools-core/query.md
+++ b/docs/design/sysml2-tools-core/query.md
@@ -110,7 +110,13 @@ flowchart TD
6. `Impact` performs a breadth-first transitive closure over incoming edges, optionally bounded
by `WalkDepth`, with a visited set preventing infinite loops on cyclic graphs.
7. `Describe` reports the target element's own kind and qualified name in `Summary`, then adds
- resolved supertypes, typing, annotations, applied metadata annotations, and direct children.
+ resolved supertypes, typing, annotations, applied metadata annotations, and a `Children: N`
+ count. `N` is the count of visible, named child entries actually placed in `Entries` (i.e.
+ direct children with a non-null `QualifiedName` that pass the `IncludeStdlib` visibility
+ rule), not the raw `element.Children.Count` - so the stated count always matches the number
+ of rows shown, even though non-element children (comments, metadata annotations, imports)
+ and, when `IncludeStdlib` is unset, stdlib-seeded children are present in `element.Children`
+ but excluded from both the count and the table.
Metadata values preserve scalar booleans, numbers, and strings directly, while unsupported
non-scalar values fall back to raw source text so information is never silently dropped.
8. `Hierarchy` walks specialization relationships recursively. `--direction up` follows
@@ -131,9 +137,16 @@ flowchart TD
supplied.
14. `QueryResultRenderer` sorts entries exactly once by `QualifiedName` (ordinal) for both
Markdown and JSON. All verbs except `dependencies` render Markdown as a heading, optional
- summary bullet list, and a shared table. `dependencies` is the one intentional exception:
- Markdown is rendered as direction-grouped prose bullets after shortening the subject and
- entry names with `QualifiedNameShortener`. `RenderJson` never shortens names and uses
+ summary bullet list, a verb-specific bold-text label (e.g. `**Children**` for `describe`,
+ `**Uses**` for `uses`), and then either the shared table or a verb-specific "no entries"
+ fallback line (e.g. `_No children._`) when there are none. Labeling the (possibly empty)
+ entries section tells the reader what kind of thing it holds, so a zero-row result reads as
+ an unremarkable, expected outcome rather than a broken query. The label is always plain bold
+ text, never an ATX heading, so the whole report stays within the single Markdown section
+ started by the main heading regardless of the caller's requested heading depth.
+ `dependencies` is the one intentional exception: Markdown is rendered as direction-grouped
+ prose bullets after shortening the subject and entry names with `QualifiedNameShortener`,
+ with no separate label. `RenderJson` never shortens names and uses
`QueryResultSerializerContext` so the JSON shape remains fully qualified and AOT-safe.
15. `QueryResultExporter` renders first, then writes the exact Markdown or JSON text to the
caller-supplied file path. Markdown is joined with `"\n"`; no parent-directory creation or
diff --git a/docs/reqstream/sysml2-tools-core/query.yaml b/docs/reqstream/sysml2-tools-core/query.yaml
index 057cd680..3b19993b 100644
--- a/docs/reqstream/sysml2-tools-core/query.yaml
+++ b/docs/reqstream/sysml2-tools-core/query.yaml
@@ -106,12 +106,17 @@ sections:
- id: SysML2Tools-Core-Query-Describe
title: >-
The Query subsystem shall report a target element's own kind, qualified name, resolved
- supertypes, typing, annotations, applied metadata annotations, and direct children
- through the `describe` verb.
+ supertypes, typing, annotations, applied metadata annotations, and a `Children` count
+ through the `describe` verb, where the reported count always equals the number of
+ entries actually returned (visible, named children), never the raw count of the
+ element's underlying AST child nodes.
justification: |
A single-element fact sheet is the most common starting point for programmatic model
exploration, consolidating information otherwise scattered across the raw source and
- semantic model.
+ semantic model. A `Children` count that disagrees with the number of rows shown (as
+ happens when non-element children, such as comments/metadata/imports, or hidden
+ stdlib children, are counted but not listed) misleads a reader into thinking rows are
+ missing or the query is broken.
tests:
- Describe_ReportsKindSupertypesAnnotationsAndChildren
- Describe_MultiLineComment_CollapsesToSingleSummaryLine
@@ -121,6 +126,7 @@ sections:
- Describe_MetadataWithUnsupportedListAttribute_FallsBackToRawText
- Describe_MultipleMetadataAnnotations_ReportsEachIndependently
- Describe_FormatJson_IncludesMetadataInSummaryArray
+ - Describe_ChildIncludesNonElementMetadataAnnotation_ChildrenCountMatchesEntryRows
- id: SysML2Tools-Core-Query-Hierarchy
title: >-
@@ -236,3 +242,26 @@ sections:
- WriteJsonAsync_HappyPath_MatchesRendererOutput
- WriteMarkdown_MissingParentDirectory_PropagatesIoException
- WriteJson_MissingParentDirectory_PropagatesIoException
+
+ - id: SysML2Tools-Core-Query-EntriesLabeling
+ title: >-
+ For every verb except `dependencies`, Markdown rendering shall precede the entries
+ table (or its "no entries" fallback) with a verb-specific bold-text label and
+ fallback message naming what kind of thing the entries represent (e.g. "Children" for
+ `describe`, "Uses" for `uses`), falling back to a generic "Entries"/"_No entries._"
+ label for any verb without a specific mapping. The label shall always render as plain
+ bold text rather than an ATX heading, so the report never branches into a Markdown
+ sub-section regardless of the caller's requested heading depth.
+ justification: |
+ A bare, unlabeled table (or a bare "_No entries._" line) gives a reader no way to tell
+ whether zero rows is an unremarkable, expected outcome for that verb or a sign the
+ query is broken. Naming the entries per verb removes that ambiguity without requiring
+ the reader to already know each verb's semantics by heart. Using bold text instead of
+ a sub-heading keeps the whole report within a single Markdown section, which is easier
+ to read and simpler to embed into other documents.
+ tests:
+ - RenderMarkdown_NoEntries_ReportsNoEntries
+ - RenderMarkdown_EntriesPresent_IncludesVerbSpecificBoldLabel
+ - RenderMarkdown_UnrecognizedVerb_FallsBackToGenericEntriesLabel
+ - RenderMarkdown_ListOrFindVerb_UsesMatchingElementsLabel
+ - RenderMarkdown_MaxHeadingDepth_EntriesLabelStaysBoldTextNotHeading
diff --git a/docs/verification/sysml2-tools-core/query.md b/docs/verification/sysml2-tools-core/query.md
index d1a4ad17..4c3c519f 100644
--- a/docs/verification/sysml2-tools-core/query.md
+++ b/docs/verification/sysml2-tools-core/query.md
@@ -54,6 +54,14 @@ scenarios listed under "Test Scenarios (Tool Test Project)" below run via `dotne
- `QualifiedNameShortener.Shorten` strips only the longest shared leading `::`-segment prefix
across a pool of qualified names, always capped so every name keeps at least its own leaf
segment, and rejects `null` pools or `null` pool entries.
+- `describe`'s `Children: N` summary line always equals the number of rows in the `Entries`
+ table (visible, named children only), never the raw count of the element's underlying AST
+ child nodes.
+- Every verb except `dependencies` precedes its entries table (or "no entries" fallback) with a
+ verb-specific bold-text label and fallback message (e.g. `**Children**` / `_No children._` for
+ `describe`), falling back to a generic `**Entries**` / `_No entries._` label for any
+ unrecognized verb. The label is always plain bold text, never an ATX heading, so the report
+ never branches into a Markdown sub-section regardless of the caller's requested heading depth.
### Test Scenarios
@@ -77,7 +85,27 @@ supertypes are reported.
`QueryEngine.Describe`, and verifies that annotations appear in the result summary.
**`RenderMarkdown_NoEntries_ReportsNoEntries`**: Verifies that a non-`dependencies` result with
-no entries renders the shared Markdown `_No entries._` fallback.
+no entries is preceded by its verb-specific bold-text label (e.g. `**Uses**`) and renders the
+verb-specific "no entries" fallback text (e.g. `_No outgoing references._`) instead of an empty
+table.
+
+**`RenderMarkdown_EntriesPresent_IncludesVerbSpecificBoldLabel`**: Verifies that a
+non-`dependencies` result with entries is also preceded by its verb-specific bold-text label
+(e.g. `**Children**` for `describe`), not just the "no entries" fallback case, and that the
+label is plain bold text rather than an ATX heading.
+
+**`RenderMarkdown_UnrecognizedVerb_FallsBackToGenericEntriesLabel`**: Verifies that a verb with
+no specific entries-label mapping falls back to the generic `**Entries**` label and `_No
+entries._` fallback text.
+
+**`RenderMarkdown_ListOrFindVerb_UsesMatchingElementsLabel`**: Verifies that both `list`
+and `find` share the same `**Matching Elements**` label, since `find` is a filtered form
+of `list` and their entries mean the same thing.
+
+**`RenderMarkdown_MaxHeadingDepth_EntriesLabelStaysBoldTextNotHeading`**: Verifies that at the
+maximum Markdown heading depth (6), the entries label remains plain bold text (`**Children**`)
+rather than becoming (or being mistaken for) an ATX heading, so the report never branches into
+a Markdown sub-section at any valid heading depth.
**`RenderMarkdown_DependenciesVerb_RendersBulletProseNotTable`**: Verifies that
`dependencies` Markdown renders as grouped prose bullets rather than the shared table.
diff --git a/src/DemaConsulting.SysML2Tools.Core/Query/QueryEngine.cs b/src/DemaConsulting.SysML2Tools.Core/Query/QueryEngine.cs
index 37a7e1a3..c4571326 100644
--- a/src/DemaConsulting.SysML2Tools.Core/Query/QueryEngine.cs
+++ b/src/DemaConsulting.SysML2Tools.Core/Query/QueryEngine.cs
@@ -313,13 +313,13 @@ public static QueryResult Describe(SysmlWorkspace workspace, SysmlNode element,
}
}
- summary.Add($"Children: {element.Children.Count}");
-
var entries = element.Children
.Where(c => c.QualifiedName is not null && IsVisible(c.QualifiedName, workspace, options.IncludeStdlib))
.Select(c => new QueryResultEntry { QualifiedName = c.QualifiedName!, Kind = DescribeKind(c) })
.ToList();
+ summary.Add($"Children: {entries.Count}");
+
return new QueryResult
{
Verb = "describe",
diff --git a/src/DemaConsulting.SysML2Tools.Core/Query/QueryResultRenderer.cs b/src/DemaConsulting.SysML2Tools.Core/Query/QueryResultRenderer.cs
index 4b181488..2ce6081f 100644
--- a/src/DemaConsulting.SysML2Tools.Core/Query/QueryResultRenderer.cs
+++ b/src/DemaConsulting.SysML2Tools.Core/Query/QueryResultRenderer.cs
@@ -18,10 +18,18 @@ public static class QueryResultRenderer
{
///
/// Renders a as Markdown lines: a heading, an optional summary
- /// bullet list, then a compact table of entries sorted by
- /// (ordinal). The dependencies verb
- /// is the sole exception: after the heading, its entries are rendered as prose bullets
- /// (see ) rather than a table.
+ /// bullet list, then a verb-specific bold-text label (e.g. "Children" for
+ /// describe, "Uses" for uses) followed by a compact table of entries
+ /// sorted by (ordinal), or a verb-specific
+ /// "no entries" fallback line (e.g. "_No children._") when there are none. The label and
+ /// fallback text tell the reader what kind of thing the entries represent, so an empty
+ /// result reads as an unremarkable, expected outcome rather than a broken query. The
+ /// label is always plain bold text rather than its own heading, so the whole report
+ /// stays within the single Markdown section started by the main heading - it never
+ /// branches into a sub-section, regardless of . The
+ /// dependencies verb is the sole exception: after the heading, its entries are
+ /// rendered as prose bullets (see ) rather than a
+ /// table, with no separate label.
///
/// The result to render.
///
@@ -77,9 +85,16 @@ public static IReadOnlyList RenderMarkdown(
}
var sorted = SortEntries(result.Entries);
+ var (entriesHeading, noEntriesText) = EntriesLabel(result.Verb);
+
+ // Plain bold text (not an ATX heading) keeps the entries label within the same
+ // Markdown section as the main heading, regardless of the caller's heading depth.
+ lines.Add($"**{entriesHeading}**");
+ lines.Add("");
+
if (sorted.Count == 0)
{
- lines.Add("_No entries._");
+ lines.Add(noEntriesText);
return lines;
}
@@ -93,6 +108,31 @@ public static IReadOnlyList RenderMarkdown(
return lines;
}
+ ///
+ /// Maps a verb name to a human-readable heading for its entries table (or the fallback
+ /// text shown when it has no entries), so a reader can tell what kind of thing an empty
+ /// result means without already knowing the verb's semantics (e.g. "_No children._" for
+ /// describe makes clear that zero rows is an unremarkable, expected outcome for a
+ /// childless element, not a broken query).
+ ///
+ /// The query verb.
+ /// The heading text and "no entries" fallback text for that verb.
+ private static (string Heading, string NoEntriesText) EntriesLabel(string verb) => verb switch
+ {
+ "uses" => ("Uses", "_No outgoing references._"),
+ "used-by" => ("Used By", "_No incoming references._"),
+ "impact" => ("Impacted Elements", "_No impacted elements._"),
+ "describe" => ("Children", "_No children._"),
+ "hierarchy" => ("Related Types", "_No related types._"),
+ "requirements" => ("Requirement Relationships", "_No requirement relationships._"),
+ "interface" => ("Ports & Typed Features", "_No ports or typed features._"),
+ "connections" => ("Connection Endpoints", "_No connection endpoints._"),
+ "states" => ("States & Transitions", "_No states or transitions._"),
+ "list" or "find" => ("Matching Elements", "_No matching elements._"),
+ _ => ("Entries", "_No entries._")
+ };
+
+
///
/// Renders a as an indented JSON document via the
/// source-generated serializer, with entries
diff --git a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.resx
index 79283cc1..5beed964 100644
--- a/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.resx
+++ b/src/DemaConsulting.SysML2Tools.Tool/Query/QueryStrings.resx
@@ -245,7 +245,7 @@
sysml2tools query find --name Wheel test/SysMLModels/OMG/examples/VehicleExample/*.sysml
- Markdown output: a '# query <verb>[: <element>]' heading, an optional bullet-list summary, then a '| Qualified Name | Kind | Detail |' table (empty results print '_No entries._').
+ Markdown output: a '# query <verb>[: <element>]' heading, an optional bullet-list summary, then a verb-specific bold-text label (e.g. '**Children**') and a '| Qualified Name | Kind | Detail |' table (empty results print a verb-specific fallback, e.g. '_No children._').
JSON output: { "Verb", "Element", "Summary": [...], "Entries": [{ "QualifiedName", "Kind", "Detail", "Notes": [] }] }, entries sorted by QualifiedName.
diff --git a/test/DemaConsulting.SysML2Tools.Tests/Query/QueryRenderingTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Query/QueryRenderingTests.cs
index 59e1c26b..0a848cfc 100644
--- a/test/DemaConsulting.SysML2Tools.Tests/Query/QueryRenderingTests.cs
+++ b/test/DemaConsulting.SysML2Tools.Tests/Query/QueryRenderingTests.cs
@@ -16,8 +16,9 @@ namespace DemaConsulting.SysML2Tools.Tests.Query;
public class QueryRenderingTests
{
///
- /// RenderMarkdown on a result with no entries reports "No entries" rather than an empty
- /// table.
+ /// RenderMarkdown on a result with no entries reports a verb-specific "no entries"
+ /// fallback line (rather than an empty table), and precedes it with a verb-specific
+ /// bold-text label naming what kind of entry the (empty) table would have held.
///
[Fact]
public void RenderMarkdown_NoEntries_ReportsNoEntries()
@@ -26,7 +27,85 @@ public void RenderMarkdown_NoEntries_ReportsNoEntries()
var lines = QueryResultRenderer.RenderMarkdown(result);
- Assert.Contains(lines, l => l.Contains("No entries"));
+ Assert.Contains("**Uses**", lines);
+ Assert.Contains(lines, l => l.Contains("No outgoing references"));
+ }
+
+ ///
+ /// RenderMarkdown adds a verb-specific bold-text label before the entries table (not
+ /// just before the "no entries" fallback), naming what kind of thing the table's rows
+ /// represent for that verb. The label is plain bold text, not an ATX heading, so the
+ /// whole report stays within the single Markdown section started by the main heading.
+ ///
+ [Fact]
+ public void RenderMarkdown_EntriesPresent_IncludesVerbSpecificBoldLabel()
+ {
+ var result = new QueryResult
+ {
+ Verb = "describe",
+ Element = "Model::Car",
+ Entries = [new QueryResultEntry { QualifiedName = "Model::Car::engine" }]
+ };
+
+ var lines = QueryResultRenderer.RenderMarkdown(result);
+
+ Assert.Contains("**Children**", lines);
+ Assert.DoesNotContain(lines, l => l.StartsWith('#') && l.Contains("Children"));
+ }
+
+ ///
+ /// RenderMarkdown gives an unrecognized verb the generic "Entries" label and "_No
+ /// entries._" fallback text, so any future verb added without an explicit label still
+ /// renders sensibly instead of throwing.
+ ///
+ [Fact]
+ public void RenderMarkdown_UnrecognizedVerb_FallsBackToGenericEntriesLabel()
+ {
+ var result = new QueryResult { Verb = "some-future-verb", Element = "Model::Car" };
+
+ var lines = QueryResultRenderer.RenderMarkdown(result);
+
+ Assert.Contains("**Entries**", lines);
+ Assert.Contains("_No entries._", lines);
+ }
+
+ ///
+ /// RenderMarkdown's entries label is always plain bold text - never an ATX heading -
+ /// regardless of the caller's requested heading depth, so the report never branches
+ /// into a Markdown sub-section even at the maximum heading depth (6).
+ ///
+ [Fact]
+ public void RenderMarkdown_MaxHeadingDepth_EntriesLabelStaysBoldTextNotHeading()
+ {
+ var result = new QueryResult
+ {
+ Verb = "describe",
+ Element = "Model::Car",
+ Entries = [new QueryResultEntry { QualifiedName = "Model::Car::engine" }]
+ };
+
+ var lines = QueryResultRenderer.RenderMarkdown(result, depth: 6);
+
+ Assert.Equal("###### query describe: Model::Car", lines[0]);
+ Assert.Contains("**Children**", lines);
+ Assert.DoesNotContain(lines.Skip(1), l => l.StartsWith('#'));
+ }
+
+ ///
+ /// RenderMarkdown gives both "list" and "find" the same "Matching Elements" bold-text
+ /// label, since "find" is a filtered form of "list" and their entries mean the same
+ /// thing.
+ ///
+ [Theory]
+ [InlineData("list")]
+ [InlineData("find")]
+ public void RenderMarkdown_ListOrFindVerb_UsesMatchingElementsLabel(string verb)
+ {
+ var result = new QueryResult { Verb = verb };
+
+ var lines = QueryResultRenderer.RenderMarkdown(result);
+
+ Assert.Contains("**Matching Elements**", lines);
}
///
diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs
index c1980752..0409225b 100644
--- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs
+++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs
@@ -238,6 +238,40 @@ part def Car specializes Vehicle {
Assert.Contains("Children: 1", output);
}
+ ///
+ /// 'describe' reports a Children: N count that always matches the number of rows
+ /// in the Entries table. A bare metadata annotation (@Critical;) is a non-element
+ /// child with no qualified name, so it is present in the underlying AST child list but
+ /// excluded from both the count and the table; only the one real child (engine)
+ /// is counted and shown.
+ ///
+ [Fact]
+ public async Task Describe_ChildIncludesNonElementMetadataAnnotation_ChildrenCountMatchesEntryRows()
+ {
+ const string sysml = """
+ package Model {
+ metadata def Critical;
+
+ part def Car {
+ @Critical;
+ part engine;
+ }
+ }
+ """;
+
+ var (output, exitCode) = await QueryTestFixtures.RunQueryAsync(
+ sysml, "describe", "--element", "Model::Car");
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("Children: 1", output);
+ Assert.Contains("Model::Car::engine", output);
+
+ var tableRows = output
+ .Split('\n')
+ .Count(line => line.TrimStart().StartsWith("| Model::Car::", StringComparison.Ordinal));
+ Assert.Equal(1, tableRows);
+ }
+
///
/// 'describe' collapses a multi-line comment/documentation annotation into a single
/// summary line, so the Markdown output keeps one fact per bullet rather than letting
@@ -265,9 +299,11 @@ part def Car {
Assert.Contains("Documentation: A multi-line doc comment.", output);
// The bullet list must remain one fact per line: no bullet's text should itself
- // contain an embedded newline, and no orphan '*' continuation-marker lines exist.
+ // contain an embedded newline, and no orphan '* ' continuation-marker lines exist
+ // (checked as "* " with a trailing space, so this doesn't false-positive on
+ // legitimate "**Bold**" entries-label lines, which have no space after the stars).
var lines = output.Split('\n');
- Assert.DoesNotContain(lines, line => line.Trim().StartsWith('*'));
+ Assert.DoesNotContain(lines, line => line.Trim().StartsWith("* ", StringComparison.Ordinal));
}
///