From 34185dd778cc40682b123632eca260c29212fb1a Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 23 Jun 2026 00:33:07 -0400 Subject: [PATCH 01/62] feat: restructure review hierarchy with Purpose/Decomposition/Architecture context flow - Rename Specification review to Decomposition (repo-level, no system prefix) - Rename Purpose review to repo-level (no system prefix) - Add README + user_guide context to all Architecture reviews - Add system requirements as context to all Design reviews - Add system requirements as context to all Verification reviews - Move system-level requirements from paths to context in AllRequirements reviews - Upgrade ReviewMark to 1.3.0-beta.3 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .config/dotnet-tools.json | 2 +- .reviewmark.yaml | 207 +++++++++++++++++++++++++++++++++++--- 2 files changed, 194 insertions(+), 15 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 205b313..086ef99 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -59,7 +59,7 @@ "rollForward": false }, "demaconsulting.reviewmark": { - "version": "1.2.0", + "version": "1.3.0-beta.3", "commands": [ "reviewmark" ], diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 0e76982..71316a2 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -1,9 +1,7 @@ --- # ReviewMark Configuration File -# This file defines which files require review, where the evidence store is located, -# and how files are grouped into named review-sets following hierarchical scope principles. -# Patterns identifying all files that require review. -# Processed in order; prefix a pattern with '!' to exclude. + +# Files needing review needs-review: - README.md # Root README file - requirements.yaml # Root requirements file @@ -17,22 +15,40 @@ needs-review: - '!**/bin/**' # Exclude build output - '!**/generated/**' # Exclude generated output - '!**/VhdlAst/Antlr/**' # Exclude ANTLR4-generated lexer/parser files + +# Source of review evidence evidence-source: type: none + +# Context included for all reviews. +context: + - docs/design/introduction.md # Software structure overview — shown to every reviewer + +# Reviews reviews: # Purpose - - id: ApiMark-Purpose - title: Review that Advertised Features Match System Design + - id: Purpose + title: Review that README and User Guide are Coherent and Complete paths: - README.md - - requirements.yaml - docs/user_guide/**/*.md + + # Decomposition + - id: Decomposition + title: Review that ApiMark Decomposition Addresses the Stated Purpose + context: + - README.md + - docs/user_guide/**/*.md + paths: + - requirements.yaml - docs/design/introduction.md - - docs/verification/introduction.md # ApiMarkCore - Specials - id: ApiMark-Core-Architecture title: Review that ApiMark Core Architecture Satisfies Requirements + context: + - README.md + - docs/user_guide/**/*.md paths: - docs/reqstream/api-mark-core.yaml - docs/design/introduction.md @@ -41,29 +57,41 @@ reviews: - docs/verification/api-mark-core.md - id: ApiMark-Core-AllRequirements title: Review that All ApiMark Core Requirements are Complete - paths: + context: + - docs/design/api-mark-core.md - docs/reqstream/api-mark-core.yaml + paths: - docs/reqstream/api-mark-core/**/*.yaml - id: ApiMark-Core-Design title: Review that ApiMark Core Design is Consistent and Complete + context: + - docs/reqstream/api-mark-core.yaml paths: - docs/design/ots.md - docs/design/api-mark-core.md - docs/design/api-mark-core/**/*.md - id: ApiMark-Core-Verification title: Review that ApiMark Core Verification is Consistent and Complete + context: + - docs/reqstream/api-mark-core.yaml paths: - docs/verification/ots.md - docs/verification/api-mark-core.md - docs/verification/api-mark-core/**/*.md - id: ApiMark-Core-TestHelpers title: Review that ApiMark Core TestHelpers Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - test/ApiMark.Core.TestHelpers/**/*.cs # ApiMarkCore - Units - id: ApiMark-Core-IApiGenerator title: Review that ApiMark Core IApiGenerator Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - docs/reqstream/api-mark-core/i-api-generator.yaml - docs/design/api-mark-core/i-api-generator.md @@ -72,6 +100,9 @@ reviews: - test/ApiMark.Core.Tests/IApiGeneratorTests.cs - id: ApiMark-Core-IApiEmitter title: Review that ApiMark Core IApiEmitter Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - docs/reqstream/api-mark-core/i-api-emitter.yaml - docs/design/api-mark-core/i-api-emitter.md @@ -80,6 +111,9 @@ reviews: - test/ApiMark.Core.Tests/IApiEmitterTests.cs - id: ApiMark-Core-EmitConfig title: Review that ApiMark Core EmitConfig Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - docs/reqstream/api-mark-core/emit-config.yaml - docs/design/api-mark-core/emit-config.md @@ -88,6 +122,9 @@ reviews: - src/ApiMark.Core/OutputFormat.cs - id: ApiMark-Core-IContext title: Review that ApiMark Core IContext Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - docs/reqstream/api-mark-core/i-context.yaml - docs/design/api-mark-core/i-context.md @@ -97,6 +134,9 @@ reviews: - test/ApiMark.Core.Tests/IContextTests.cs - id: ApiMark-Core-MarkdownWriter title: Review that ApiMark Core IMarkdownWriter Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - docs/reqstream/api-mark-core/i-markdown-writer.yaml - docs/design/api-mark-core/i-markdown-writer.md @@ -107,6 +147,9 @@ reviews: - test/ApiMark.Core.Tests/FileMarkdownWriterTests.cs - id: ApiMark-Core-MarkdownWriterFactory title: Review that ApiMark Core IMarkdownWriterFactory Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml - docs/design/api-mark-core/i-markdown-writer-factory.md @@ -117,6 +160,9 @@ reviews: - test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs - id: ApiMark-Core-PathHelpers title: Review that ApiMark Core PathHelpers Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - docs/reqstream/api-mark-core/path-helpers.yaml - docs/design/api-mark-core/path-helpers.md @@ -125,6 +171,9 @@ reviews: - test/ApiMark.Core.Tests/PathHelpersTests.cs - id: ApiMark-Core-GlobFileCollector title: Review that ApiMark Core GlobFileCollector Implementation is Correct + context: + - docs/design/api-mark-core.md + - docs/reqstream/api-mark-core.yaml paths: - docs/reqstream/api-mark-core/glob-file-collector.yaml - docs/design/api-mark-core/glob-file-collector.md @@ -135,6 +184,9 @@ reviews: # ApiMarkDotNet - Specials - id: ApiMark-DotNet-Architecture title: Review that ApiMark DotNet Architecture Satisfies Requirements + context: + - README.md + - docs/user_guide/**/*.md paths: - docs/reqstream/api-mark-dot-net.yaml - docs/design/introduction.md @@ -143,17 +195,23 @@ reviews: - docs/verification/api-mark-dot-net.md - id: ApiMark-DotNet-AllRequirements title: Review that All ApiMark DotNet Requirements are Complete - paths: + context: + - docs/design/api-mark-dot-net.md - docs/reqstream/api-mark-dot-net.yaml + paths: - docs/reqstream/api-mark-dot-net/**/*.yaml - id: ApiMark-DotNet-Design title: Review that ApiMark DotNet Design is Consistent and Complete + context: + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/design/ots.md - docs/design/api-mark-dot-net.md - docs/design/api-mark-dot-net/**/*.md - id: ApiMark-DotNet-Verification title: Review that ApiMark DotNet Verification is Consistent and Complete + context: + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/verification/ots.md - docs/verification/api-mark-dot-net.md @@ -162,6 +220,9 @@ reviews: # ApiMarkDotNet - Units - id: ApiMark-DotNet-DotNetGenerator title: Review that ApiMark DotNet DotNetGenerator Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/reqstream/api-mark-dot-net/dot-net-generator.yaml - docs/design/api-mark-dot-net/dot-net-generator.md @@ -173,6 +234,9 @@ reviews: - test/ApiMark.DotNet.Tests/FixturePaths.cs - id: ApiMark-DotNet-DotNetAstModel title: Review that ApiMark DotNet DotNetAstModel Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml - docs/design/api-mark-dot-net/dot-net-ast-model.md @@ -181,6 +245,9 @@ reviews: - test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs - id: ApiMark-DotNet-DotNetEmitter title: Review that ApiMark DotNet DotNetEmitter Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/reqstream/api-mark-dot-net/dot-net-emitter.yaml - docs/design/api-mark-dot-net/dot-net-emitter.md @@ -191,6 +258,9 @@ reviews: - id: ApiMark-DotNet-DotNetEmitterGradualDisclosure title: Review that ApiMark DotNet DotNetEmitterGradualDisclosure Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml - docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -200,6 +270,9 @@ reviews: - test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs - id: ApiMark-DotNet-DotNetEmitterSingleFile title: Review that ApiMark DotNet DotNetEmitterSingleFile Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml - docs/design/api-mark-dot-net/dot-net-emitter-single-file.md @@ -209,6 +282,9 @@ reviews: - test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs - id: ApiMark-DotNet-XmlDocReader title: Review that ApiMark DotNet XmlDocReader Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml - docs/design/api-mark-dot-net/xml-doc-reader.md @@ -217,6 +293,9 @@ reviews: - test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs - id: ApiMark-DotNet-TypeLinkResolver title: Review that ApiMark DotNet TypeLinkResolver Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/reqstream/api-mark-dot-net/type-link-resolver.yaml - docs/design/api-mark-dot-net/type-link-resolver.md @@ -225,6 +304,9 @@ reviews: - test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs - id: ApiMark-DotNet-TypeNameSimplifier title: Review that ApiMark DotNet TypeNameSimplifier Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml paths: - docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml - docs/design/api-mark-dot-net/type-name-simplifier.md @@ -235,6 +317,9 @@ reviews: # ApiMarkCpp - Specials - id: ApiMark-Cpp-Architecture title: Review that ApiMark Cpp Architecture Satisfies Requirements + context: + - README.md + - docs/user_guide/**/*.md paths: - docs/reqstream/api-mark-cpp.yaml - docs/design/introduction.md @@ -243,17 +328,23 @@ reviews: - docs/verification/api-mark-cpp.md - id: ApiMark-Cpp-AllRequirements title: Review that All ApiMark Cpp Requirements are Complete - paths: + context: + - docs/design/api-mark-cpp.md - docs/reqstream/api-mark-cpp.yaml + paths: - docs/reqstream/api-mark-cpp/**/*.yaml - id: ApiMark-Cpp-Design title: Review that ApiMark Cpp Design is Consistent and Complete + context: + - docs/reqstream/api-mark-cpp.yaml paths: - docs/design/ots.md - docs/design/api-mark-cpp.md - docs/design/api-mark-cpp/**/*.md - id: ApiMark-Cpp-Verification title: Review that ApiMark Cpp Verification is Consistent and Complete + context: + - docs/reqstream/api-mark-cpp.yaml paths: - docs/verification/ots.md - docs/verification/api-mark-cpp.md @@ -262,6 +353,9 @@ reviews: # ApiMarkCpp - Units - id: ApiMark-Cpp-CppGenerator title: Review that ApiMark Cpp CppGenerator Implementation is Correct + context: + - docs/design/api-mark-cpp.md + - docs/reqstream/api-mark-cpp.yaml paths: - docs/reqstream/api-mark-cpp/cpp-generator.yaml - docs/design/api-mark-cpp/cpp-generator.md @@ -274,6 +368,9 @@ reviews: - test/ApiMark.Cpp.Tests/FixturePaths.cs - id: ApiMark-Cpp-CppEmitter title: Review that ApiMark Cpp CppEmitter Implementation is Correct + context: + - docs/design/api-mark-cpp.md + - docs/reqstream/api-mark-cpp.yaml paths: - docs/reqstream/api-mark-cpp/cpp-emitter.yaml - docs/design/api-mark-cpp/cpp-emitter.md @@ -283,6 +380,9 @@ reviews: - test/ApiMark.Cpp.Tests/CppEmitterTests.cs - id: ApiMark-Cpp-CppEmitterGradualDisclosure title: Review that ApiMark Cpp CppEmitterGradualDisclosure Implementation is Correct + context: + - docs/design/api-mark-cpp.md + - docs/reqstream/api-mark-cpp.yaml paths: - docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml - docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -292,6 +392,9 @@ reviews: - test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs - id: ApiMark-Cpp-CppEmitterSingleFile title: Review that ApiMark Cpp CppEmitterSingleFile Implementation is Correct + context: + - docs/design/api-mark-cpp.md + - docs/reqstream/api-mark-cpp.yaml paths: - docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml - docs/design/api-mark-cpp/cpp-emitter-single-file.md @@ -300,6 +403,9 @@ reviews: - test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs - id: ApiMark-Cpp-CppTypeLinkResolver title: Review that ApiMark Cpp CppTypeLinkResolver Implementation is Correct + context: + - docs/design/api-mark-cpp.md + - docs/reqstream/api-mark-cpp.yaml paths: - docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml - docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -310,6 +416,9 @@ reviews: # ApiMarkCpp.CppAst - Units - id: ApiMark-Cpp-CppAstModel title: Review that ApiMark Cpp CppAstModel Implementation is Correct + context: + - docs/design/api-mark-cpp.md + - docs/reqstream/api-mark-cpp.yaml paths: - docs/reqstream/api-mark-cpp/cpp-ast-model.yaml - docs/design/api-mark-cpp/cpp-ast-model.md @@ -318,6 +427,9 @@ reviews: - test/ApiMark.Cpp.Tests/CppAstModelTests.cs - id: ApiMark-Cpp-ClangAstParser title: Review that ApiMark Cpp ClangAstParser Implementation is Correct + context: + - docs/design/api-mark-cpp.md + - docs/reqstream/api-mark-cpp.yaml paths: - docs/reqstream/api-mark-cpp/clang-ast-parser.yaml - docs/design/api-mark-cpp/clang-ast-parser.md @@ -329,6 +441,9 @@ reviews: # ApiMarkVhdl - Specials - id: ApiMark-Vhdl-Architecture title: Review that ApiMark Vhdl Architecture Satisfies Requirements + context: + - README.md + - docs/user_guide/**/*.md paths: - docs/reqstream/api-mark-vhdl.yaml - docs/design/introduction.md @@ -337,17 +452,23 @@ reviews: - docs/verification/api-mark-vhdl.md - id: ApiMark-Vhdl-AllRequirements title: Review that All ApiMark Vhdl Requirements are Complete - paths: + context: + - docs/design/api-mark-vhdl.md - docs/reqstream/api-mark-vhdl.yaml + paths: - docs/reqstream/api-mark-vhdl/**/*.yaml - id: ApiMark-Vhdl-Design title: Review that ApiMark Vhdl Design is Consistent and Complete + context: + - docs/reqstream/api-mark-vhdl.yaml paths: - docs/design/ots.md - docs/design/api-mark-vhdl.md - docs/design/api-mark-vhdl/**/*.md - id: ApiMark-Vhdl-Verification title: Review that ApiMark Vhdl Verification is Consistent and Complete + context: + - docs/reqstream/api-mark-vhdl.yaml paths: - docs/verification/ots.md - docs/verification/api-mark-vhdl.md @@ -356,6 +477,9 @@ reviews: # ApiMarkVhdl - Units - id: ApiMark-Vhdl-VhdlGenerator title: Review that ApiMark Vhdl VhdlGenerator Implementation is Correct + context: + - docs/design/api-mark-vhdl.md + - docs/reqstream/api-mark-vhdl.yaml paths: - docs/reqstream/api-mark-vhdl/vhdl-generator.yaml - docs/design/api-mark-vhdl/vhdl-generator.md @@ -366,6 +490,9 @@ reviews: - test/ApiMark.Vhdl.Tests/FixturePaths.cs - id: ApiMark-Vhdl-VhdlEmitter title: Review that ApiMark Vhdl VhdlEmitter Implementation is Correct + context: + - docs/design/api-mark-vhdl.md + - docs/reqstream/api-mark-vhdl.yaml paths: - docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml - docs/design/api-mark-vhdl/vhdl-emitter.md @@ -375,6 +502,9 @@ reviews: - id: ApiMark-Vhdl-VhdlEmitterGradualDisclosure title: Review that ApiMark Vhdl VhdlEmitterGradualDisclosure Implementation is Correct + context: + - docs/design/api-mark-vhdl.md + - docs/reqstream/api-mark-vhdl.yaml paths: - docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml - docs/design/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md @@ -384,6 +514,9 @@ reviews: - test/ApiMark.Vhdl.Tests/VhdlEmitterGradualDisclosureTests.cs - id: ApiMark-Vhdl-VhdlEmitterSingleFile title: Review that ApiMark Vhdl VhdlEmitterSingleFile Implementation is Correct + context: + - docs/design/api-mark-vhdl.md + - docs/reqstream/api-mark-vhdl.yaml paths: - docs/reqstream/api-mark-vhdl/vhdl-emitter-single-file.yaml - docs/design/api-mark-vhdl/vhdl-emitter-single-file.md @@ -395,6 +528,9 @@ reviews: # ApiMarkVhdl.VhdlAst - Units - id: ApiMark-Vhdl-VhdlAstModel title: Review that ApiMark Vhdl VhdlAstModel Implementation is Correct + context: + - docs/design/api-mark-vhdl.md + - docs/reqstream/api-mark-vhdl.yaml paths: - docs/reqstream/api-mark-vhdl/vhdl-ast-model.yaml - docs/design/api-mark-vhdl/vhdl-ast-model.md @@ -403,6 +539,9 @@ reviews: - test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs - id: ApiMark-Vhdl-VhdlAstParser title: Review that ApiMark Vhdl VhdlAstParser Implementation is Correct + context: + - docs/design/api-mark-vhdl.md + - docs/reqstream/api-mark-vhdl.yaml paths: - docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml - docs/design/api-mark-vhdl/vhdl-ast-parser.md @@ -414,6 +553,9 @@ reviews: # ApiMarkMSBuild - Specials - id: ApiMark-MSBuild-Architecture title: Review that ApiMark MSBuild Architecture Satisfies Requirements + context: + - README.md + - docs/user_guide/**/*.md paths: - docs/reqstream/api-mark-msbuild.yaml - docs/design/introduction.md @@ -422,17 +564,23 @@ reviews: - docs/verification/api-mark-msbuild.md - id: ApiMark-MSBuild-AllRequirements title: Review that All ApiMark MSBuild Requirements are Complete - paths: + context: + - docs/design/api-mark-msbuild.md - docs/reqstream/api-mark-msbuild.yaml + paths: - docs/reqstream/api-mark-msbuild/**/*.yaml - id: ApiMark-MSBuild-Design title: Review that ApiMark MSBuild Design is Consistent and Complete + context: + - docs/reqstream/api-mark-msbuild.yaml paths: - docs/design/ots.md - docs/design/api-mark-msbuild.md - docs/design/api-mark-msbuild/**/*.md - id: ApiMark-MSBuild-Verification title: Review that ApiMark MSBuild Verification is Consistent and Complete + context: + - docs/reqstream/api-mark-msbuild.yaml paths: - docs/verification/ots.md - docs/verification/api-mark-msbuild.md @@ -441,6 +589,9 @@ reviews: # ApiMarkMSBuild - Units - id: ApiMark-MSBuild-Task title: Review that ApiMark MSBuild ApiMarkTask Implementation is Correct + context: + - docs/design/api-mark-msbuild.md + - docs/reqstream/api-mark-msbuild.yaml paths: - docs/reqstream/api-mark-msbuild/api-mark-task.yaml - docs/design/api-mark-msbuild/api-mark-task.md @@ -454,6 +605,9 @@ reviews: # ApiMarkTool - Specials - id: ApiMark-Tool-Architecture title: Review that ApiMark Tool Architecture Satisfies Requirements + context: + - README.md + - docs/user_guide/**/*.md paths: - docs/reqstream/api-mark-tool.yaml - docs/design/introduction.md @@ -462,17 +616,23 @@ reviews: - docs/verification/api-mark-tool.md - id: ApiMark-Tool-AllRequirements title: Review that All ApiMark Tool Requirements are Complete - paths: + context: + - docs/design/api-mark-tool.md - docs/reqstream/api-mark-tool.yaml + paths: - docs/reqstream/api-mark-tool/**/*.yaml - id: ApiMark-Tool-Design title: Review that ApiMark Tool Design is Consistent and Complete + context: + - docs/reqstream/api-mark-tool.yaml paths: - docs/design/ots.md - docs/design/api-mark-tool.md - docs/design/api-mark-tool/**/*.md - id: ApiMark-Tool-Verification title: Review that ApiMark Tool Verification is Consistent and Complete + context: + - docs/reqstream/api-mark-tool.yaml paths: - docs/verification/ots.md - docs/verification/api-mark-tool.md @@ -481,6 +641,9 @@ reviews: # ApiMarkTool - Units - id: ApiMark-Tool-Program title: Review that ApiMark Tool Program Implementation is Correct + context: + - docs/design/api-mark-tool.md + - docs/reqstream/api-mark-tool.yaml paths: - docs/reqstream/api-mark-tool/program.yaml - docs/design/api-mark-tool/program.md @@ -491,12 +654,20 @@ reviews: # ApiMarkTool.Cli - Units - id: ApiMark-Tool-Cli title: Review that ApiMark Tool Cli Satisfies Subsystem Requirements + context: + - docs/design/api-mark-tool.md + - docs/reqstream/api-mark-tool.yaml paths: - docs/reqstream/api-mark-tool/cli.yaml - docs/design/api-mark-tool/cli.md - docs/verification/api-mark-tool/cli.md - id: ApiMark-Tool-Cli-Context title: Review that ApiMark Tool Cli Context Implementation is Correct + context: + - docs/design/api-mark-tool.md + - docs/reqstream/api-mark-tool.yaml + - docs/design/api-mark-tool/cli.md + - docs/reqstream/api-mark-tool/cli.yaml paths: - docs/reqstream/api-mark-tool/cli/context.yaml - docs/design/api-mark-tool/cli/context.md @@ -507,12 +678,20 @@ reviews: # ApiMarkTool.SelfTest - Units - id: ApiMark-Tool-SelfTest title: Review that ApiMark Tool SelfTest Satisfies Subsystem Requirements + context: + - docs/design/api-mark-tool.md + - docs/reqstream/api-mark-tool.yaml paths: - docs/reqstream/api-mark-tool/self-test.yaml - docs/design/api-mark-tool/self-test.md - docs/verification/api-mark-tool/self-test.md - id: ApiMark-Tool-SelfTest-Validation title: Review that ApiMark Tool SelfTest Validation Implementation is Correct + context: + - docs/design/api-mark-tool.md + - docs/reqstream/api-mark-tool.yaml + - docs/design/api-mark-tool/self-test.md + - docs/reqstream/api-mark-tool/self-test.yaml paths: - docs/reqstream/api-mark-tool/self-test/validation.yaml - docs/design/api-mark-tool/self-test/validation.md From 19a697a480934ad2de4bed6b88d41a7e7fc16684 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 23 Jun 2026 18:15:32 -0400 Subject: [PATCH 02/62] Add ReviewMark context hierarchy and harden ApiMark.Core contracts - Add 8-level review context hierarchy to .reviewmark.yaml so every software item receives design, requirements, and parent-system context during formal review - Update ReviewMark to 1.3.0; exclude design intro from Purpose review using '!' syntax to prevent design back-feeding into user-doc reviews - Add verification/introduction.md to Core-Verification review-set paths ApiMark.Core contract hardening (driven by 9 rounds of formal review): - EmitConfig: validate HeadingDepth range 1-3 at construction - FileMarkdownWriter: null/empty/shape guards on all 6 write methods; WriteTable validates headers non-empty, row lengths, null rows/elements - GlobFileCollector: StringComparer.Ordinal fix; null guards on Collect - PathHelpers: zero-segment returns basePath; clarified error handling - IMarkdownWriterFactory: whitespace subFolder treated as root-level - IApiGenerator/IApiEmitter: XML doc corrections and requirement splits Test helpers hardened to enforce same preconditions as production code: - InMemoryMarkdownWriter: heading range 1-6, table shape, null elements - InMemoryContext: null guards New artifacts for FileMarkdownWriter and FileMarkdownWriterFactory: - docs/design, docs/reqstream, docs/verification unit documents - EmitConfigTests.cs created Documentation: - design/introduction.md: scope clarified; test/ removed from Folder Layout - verification/introduction.md: scope clarified; Folder Layout section added - i-markdown-writer.md: preconditions documented for all write methods - N/A em-dash sweep across 36 design/verification docs - docs/design/ots.md: OTS strategy split by type; lock-file claim removed - docs/user_guide/cpp.md: @code/@endcode authoring section added - README.md: ClangPath -> ApiMarkClangPath 114 Core tests passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .config/dotnet-tools.json | 2 +- .github/agents/formal-review.agent.md | 12 +- .github/standards/reviewmark-usage.md | 132 +++--- .reviewmark.yaml | 126 ++---- README.md | 5 +- docs/design/api-mark-core.md | 13 +- docs/design/api-mark-core/emit-config.md | 25 +- .../file-markdown-writer-factory.md | 84 ++++ .../api-mark-core/file-markdown-writer.md | 108 +++++ .../api-mark-core/glob-file-collector.md | 24 +- docs/design/api-mark-core/i-api-emitter.md | 6 +- docs/design/api-mark-core/i-api-generator.md | 2 +- docs/design/api-mark-core/i-context.md | 4 +- .../design/api-mark-core/i-markdown-writer.md | 21 +- docs/design/api-mark-core/path-helpers.md | 19 +- docs/design/api-mark-cpp.md | 2 +- docs/design/api-mark-cpp/cpp-ast-model.md | 8 +- .../cpp-emitter-gradual-disclosure.md | 2 +- .../api-mark-cpp/cpp-emitter-single-file.md | 4 +- .../api-mark-cpp/cpp-type-link-resolver.md | 6 +- docs/design/api-mark-dot-net.md | 2 +- .../dot-net-emitter-single-file.md | 2 +- .../api-mark-dot-net/type-name-simplifier.md | 2 +- docs/design/api-mark-msbuild.md | 2 +- docs/design/api-mark-msbuild/api-mark-task.md | 2 +- docs/design/api-mark-tool.md | 2 +- docs/design/api-mark-tool/cli.md | 2 +- docs/design/api-mark-tool/cli/context.md | 2 +- docs/design/api-mark-tool/program.md | 2 +- .../api-mark-tool/self-test/validation.md | 2 +- docs/design/api-mark-vhdl.md | 2 +- docs/design/api-mark-vhdl/vhdl-ast-model.md | 6 +- docs/design/api-mark-vhdl/vhdl-ast-parser.md | 2 +- docs/design/introduction.md | 49 +-- docs/design/ots.md | 20 +- docs/reqstream/api-mark-core.yaml | 17 +- docs/reqstream/api-mark-core/emit-config.yaml | 22 +- .../file-markdown-writer-factory.yaml | 77 ++++ .../api-mark-core/file-markdown-writer.yaml | 230 +++++++++++ .../api-mark-core/glob-file-collector.yaml | 23 ++ .../api-mark-core/i-api-emitter.yaml | 32 +- .../api-mark-core/i-api-generator.yaml | 33 +- docs/reqstream/api-mark-core/i-context.yaml | 20 + .../i-markdown-writer-factory.yaml | 43 +- .../api-mark-core/i-markdown-writer.yaml | 89 ++++ .../reqstream/api-mark-core/path-helpers.yaml | 9 +- docs/user_guide/cpp.md | 35 ++ docs/user_guide/introduction.md | 9 +- docs/verification/api-mark-core.md | 32 +- .../verification/api-mark-core/emit-config.md | 48 ++- .../file-markdown-writer-factory.md | 79 ++++ .../api-mark-core/file-markdown-writer.md | 168 ++++++++ .../api-mark-core/glob-file-collector.md | 29 +- .../api-mark-core/i-api-emitter.md | 40 +- .../api-mark-core/i-api-generator.md | 38 +- docs/verification/api-mark-core/i-context.md | 23 +- .../i-markdown-writer-factory.md | 8 +- .../api-mark-core/i-markdown-writer.md | 4 +- .../api-mark-core/path-helpers.md | 5 + docs/verification/introduction.md | 42 +- docs/verification/ots.md | 21 + requirements.yaml | 2 + src/ApiMark.Core/EmitConfig.cs | 24 +- src/ApiMark.Core/FileMarkdownWriter.cs | 94 ++++- src/ApiMark.Core/GlobFileCollector.cs | 13 +- src/ApiMark.Core/IApiGenerator.cs | 2 +- src/ApiMark.Core/IMarkdownWriterFactory.cs | 12 +- src/ApiMark.Core/PathHelpers.cs | 8 +- .../InMemoryContext.cs | 6 +- .../InMemoryMarkdownWriter.cs | 114 +++++- test/ApiMark.Core.Tests/EmitConfigTests.cs | 77 ++++ .../FileMarkdownWriterFactoryTests.cs | 61 +++ .../FileMarkdownWriterTests.cs | 380 +++++++++++++++++- .../GlobFileCollectorTests.cs | 40 ++ test/ApiMark.Core.Tests/IApiEmitterTests.cs | 145 ++++++- test/ApiMark.Core.Tests/IApiGeneratorTests.cs | 121 ++---- test/ApiMark.Core.Tests/IContextTests.cs | 60 +++ test/ApiMark.Core.Tests/PathHelpersTests.cs | 17 + 78 files changed, 2576 insertions(+), 480 deletions(-) create mode 100644 docs/design/api-mark-core/file-markdown-writer-factory.md create mode 100644 docs/design/api-mark-core/file-markdown-writer.md create mode 100644 docs/reqstream/api-mark-core/file-markdown-writer-factory.yaml create mode 100644 docs/reqstream/api-mark-core/file-markdown-writer.yaml create mode 100644 docs/verification/api-mark-core/file-markdown-writer-factory.md create mode 100644 docs/verification/api-mark-core/file-markdown-writer.md create mode 100644 test/ApiMark.Core.Tests/EmitConfigTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 086ef99..9174c74 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -59,7 +59,7 @@ "rollForward": false }, "demaconsulting.reviewmark": { - "version": "1.3.0-beta.3", + "version": "1.3.0", "commands": [ "reviewmark" ], diff --git a/.github/agents/formal-review.agent.md b/.github/agents/formal-review.agent.md index 14e487a..cb78ddf 100644 --- a/.github/agents/formal-review.agent.md +++ b/.github/agents/formal-review.agent.md @@ -31,12 +31,14 @@ standards from the selection matrix in AGENTS.md. 1. Download the review checklist from . If the download fails, report the failure rather than proceeding without the template. -2. Use `dotnet reviewmark --elaborate {review-set}` to get the files to review -3. Review all files holistically, checking for cross-file consistency and - compliance with the review checklist -4. Save the populated review checklist to `.agent-logs/reviews/review-report-{review-set}.md`. +2. Run `dotnet reviewmark --elaborate {review-set}`. Read all files listed under + `## Context` first — these are reference material, not under review — then review + all files listed under `## Files` holistically, using the context to understand + the intended role and scope within the broader system, and checking for cross-file + consistency and compliance with the review checklist. +3. Save the populated review checklist to `.agent-logs/reviews/review-report-{review-set}.md`. This directory holds formal review artifacts, not agent logs. -5. Generate a completion report per the AGENTS.md reporting requirements. +4. Generate a completion report per the AGENTS.md reporting requirements. # Report Template diff --git a/.github/standards/reviewmark-usage.md b/.github/standards/reviewmark-usage.md index b521433..1ac5846 100644 --- a/.github/standards/reviewmark-usage.md +++ b/.github/standards/reviewmark-usage.md @@ -20,72 +20,85 @@ review, organizes them into review-sets, and generates review plans and reports. - **Lint Configuration**: `dotnet reviewmark --lint` - **Elaborate Review-Set**: `dotnet reviewmark --elaborate {review-set}` -- **Generate Plan**: `dotnet reviewmark --plan docs/code_review_plan/generated/plan.md --enforce` - -> **Note**: `--enforce` causes the plan to fail with a non-zero exit code if any repository -> files are not covered by a review-set. Uncovered files indicate a gap in review-set -> configuration that should be addressed. +- **Generate Plan**: `dotnet reviewmark --plan docs/code_review_plan/generated/plan.md --enforce` (exits non-zero if any files are uncovered) ## Repository Structure -Required repository items for ReviewMark operation: - - `.reviewmark.yaml` - Configuration for review-sets, file-patterns, and review evidence-source. -- `docs/code_review_plan/generated/` - Generated review plan (build output, do not edit) -- `docs/code_review_report/generated/` - Generated review report (build output, do not edit) # Review Definition Structure Configure reviews in `.reviewmark.yaml` at repository root: ```yaml -# Patterns identifying all files that require review needs-review: - # Include source code (adjust file extensions for your repo) - - "**/*.cs" # C# source files - - "**/*.cpp" # C++ source files - - "**/*.hpp" # C++ header files - - "!**/bin/**" # Generated source in build outputs - - "!**/obj/**" # Generated source in build intermediates - - # Include requirement files - - "requirements.yaml" # Root requirements file - - "docs/reqstream/**/*.yaml" # Requirements files - - # Include critical documentation files - - "README.md" # Root level README - - "docs/user_guide/**/*.md" # User guide - - "docs/design/**/*.md" # Design documentation - - "docs/verification/**/*.md" # Verification design documentation - -# Source of review evidence + - "**/*.cs" + - "**/*.cpp" + - "**/*.hpp" + - "!**/bin/**" + - "!**/obj/**" + - "requirements.yaml" + - "docs/reqstream/**/*.yaml" + - "README.md" + - "docs/user_guide/**/*.md" + - "docs/design/**/*.md" + - "docs/verification/**/*.md" + evidence-source: type: none -# Review-sets (each focuses on a single compliance question) +context: + - docs/design/introduction.md + reviews: - id: Purpose - title: Review of user-facing capabilities and system promises + title: Review that README and User Guide are Coherent and Complete paths: - "README.md" - "docs/user_guide/**/*.md" - - "docs/reqstream/{system-name}.yaml" + - id: Decomposition + title: Review that {SystemName} Decomposition Addresses the Stated Purpose + context: + - "README.md" + - "docs/user_guide/**/*.md" + paths: + - "requirements.yaml" - "docs/design/introduction.md" - - "docs/design/{system-name}.md" ``` -# Review-Set Design Principles +For a complete annotated example with template directives, see `.reviewmark.yaml` in the +reference template (`{template-url}/.reviewmark.yaml` per `AGENTS.md`). -When constructing review-sets, follow these principles to maintain manageable scope and effective compliance evidence: +# Review-Set Design Principles - **Hierarchical Scope**: Higher-level reviews exclude lower-level implementation details, relying instead on design documents to describe what components they use. System reviews exclude subsystem/unit details, subsystem reviews exclude unit source code, only unit reviews include actual implementation. - **Single Focus**: Each review-set proves one specific compliance question (user promises, system architecture, design consistency, etc.) +- **Parent Context**: Unit and subsystem reviews include parent design and requirements as + context so reviewers understand the intended role and scope; see the Context Files section. - **Context Management**: Keep file counts manageable to prevent context overflow while maintaining complete coverage through the hierarchy +# Context Files + +Context files are shown to reviewers for orientation but not fingerprinted. Add a top-level +`context:` key for global context (every reviewer) and a per-review-set `context:` between +`title:` and `paths:` for review-specific context. Always include `docs/design/introduction.md` +as global context. + +| Review Type | Context to add | +| :---------- | :------------- | +| `Decomposition` | `README.md`, `docs/user_guide/**/*.md` | +| `{SystemName}-Architecture` | `README.md`, `docs/user_guide/**/*.md` | +| `{SystemName}-Design` | `docs/reqstream/{system-name}.yaml` | +| `{SystemName}-Verification` | `docs/reqstream/{system-name}.yaml` | +| `{SystemName}-AllRequirements` | Parent system design doc + `docs/reqstream/{system-name}.yaml` | +| `{SystemName}-{UnitName}` (direct unit) | Parent system design doc + parent system requirements | +| `{SystemName}-{SubsystemName}` (subsystem) | Parent system design doc + parent system requirements | +| `{SystemName}-{SubsystemName}-{UnitName}` (unit under subsystem) | System + subsystem design docs, system + subsystem requirements | + # Review-Set Organization **Naming conventions**: Placeholders in documentation, requirements, design, and @@ -96,23 +109,27 @@ placeholders are always PascalCase (e.g., `{SystemName}`). ## `Purpose` Review (only one per repository) -Reviews user-facing capabilities and system promises: - -- **Purpose**: Proves that the systems provide the capabilities the user is being told about -- **Title**: "Review that Advertised Features Match System Design" -- **Scope**: Excludes subsystem and unit files, relying on system-level design documents - to describe what subsystems and units they use +- **Purpose**: Proves that the user-facing docs are coherent and complete — the north-star for the hierarchy +- **Title**: "Review that README and User Guide are Coherent and Complete" +- **ID**: `Purpose` (no system prefix — one per repository) +- **Scope**: README and user_guide only; no requirements or design files - **File Path Patterns**: - README: `README.md` - User guide: `docs/user_guide/**/*.md` - - System requirements: `docs/reqstream/{system-name}.yaml` + +## `Decomposition` Review (only one per repository) + +- **Purpose**: Proves that the software items tree breakdown logically addresses the user-facing promise; the structural mirror of the decomposition decision +- **Title**: "Review that {SystemName} Decomposition Addresses the Stated Purpose" +- **ID**: `Decomposition` (no system prefix — one per repository) +- **Scope**: introduction.md (the decomposition narrative) and requirements.yaml (the structural tree); no system-level detail +- **File Path Patterns**: + - Root requirements: `requirements.yaml` - Design introduction: `docs/design/introduction.md` - - System design: `docs/design/{system-name}.md` +- **Context Files**: `README.md`, `docs/user_guide/**/*.md` ## `{SystemName}-Architecture` Review (one per system) -Reviews system architecture and operational validation: - - **Purpose**: Proves that the system is designed and tested to satisfy its requirements - **Title**: "Review that {SystemName} Architecture Satisfies Requirements" - **Scope**: Excludes subsystem and unit files, relying on system-level design to describe @@ -124,16 +141,15 @@ Reviews system architecture and operational validation: - Verification introduction: `docs/verification/introduction.md` - System verification design: `docs/verification/{system-name}.md` - System integration tests: `test/{SystemName}.Tests/{SystemName}Tests.{ext}` +- **Context Files**: `README.md`, `docs/user_guide/**/*.md` ## `{SystemName}-Design` Review (one per system) -Reviews architectural and design consistency: - - **Purpose**: Proves the system design is consistent and complete - **Title**: "Review that {SystemName} Design is Consistent and Complete" - **Scope**: Only brings in top-level requirements and relies on brevity of design documentation +- **Context Files**: `docs/reqstream/{system-name}.yaml` - **File Path Patterns**: - - System requirements: `docs/reqstream/{system-name}.yaml` - Platform requirements: `docs/reqstream/{system-name}/platform-requirements.yaml` - Design introduction: `docs/design/introduction.md` - System design: `docs/design/{system-name}.md` @@ -143,13 +159,11 @@ Reviews architectural and design consistency: ## `{SystemName}-Verification` Review (one per system) -Reviews verification completeness and consistency: - - **Purpose**: Proves the system verification design is consistent and covers all requirements - **Title**: "Review that {SystemName} Verification is Consistent and Complete" - **Scope**: Only brings in top-level requirements and all verification docs for the system +- **Context Files**: `docs/reqstream/{system-name}.yaml` - **File Path Patterns**: - - System requirements: `docs/reqstream/{system-name}.yaml` - Verification introduction: `docs/verification/introduction.md` - System verification: `docs/verification/{system-name}.md` - System verification files: `docs/verification/{system-name}/**/*.md` @@ -158,20 +172,15 @@ Reviews verification completeness and consistency: ## `{SystemName}-AllRequirements` Review (one per system) -Reviews requirements quality and traceability: - - **Purpose**: Proves the requirements are consistent and complete - **Title**: "Review that All {SystemName} Requirements are Complete" - **Scope**: Only brings in requirements files to keep review manageable - **File Path Patterns**: - - Root requirements: `requirements.yaml` - - System requirements: `docs/reqstream/{system-name}.yaml` - Subsystem/unit requirements: `docs/reqstream/{system-name}/**/*.yaml` +- **Context Files**: `docs/design/{system-name}.md`, `docs/reqstream/{system-name}.yaml` ## `{SystemName}-{SubsystemName}[-{SubsystemName}...]` Review (one per subsystem at any depth) -Reviews subsystem architecture and interfaces: - - **Purpose**: Proves that the subsystem is designed and tested to satisfy its requirements - **Title**: "Review that {SystemName} {SubsystemName} Satisfies Subsystem Requirements" - **Scope**: Excludes units under the subsystem, relying on subsystem design to describe @@ -181,11 +190,10 @@ Reviews subsystem architecture and interfaces: - Design: `docs/design/{system-name}[/{subsystem-name}...]/{subsystem-name}.md` - Verification design: `docs/verification/{system-name}[/{subsystem-name}...]/{subsystem-name}.md` - Tests: `test/{SystemName}.Tests[/{SubsystemName}...]/{SubsystemName}Tests.{ext}` +- **Context Files**: `docs/design/{system-name}.md`, `docs/reqstream/{system-name}.yaml` ## `{SystemName}-{SubsystemName}[-{SubsystemName}...]-{UnitName}` Review (one per unit) -Reviews individual software unit implementation: - - **Purpose**: Proves the unit is designed, implemented, and tested to satisfy its requirements - **Title**: "Review that {SystemName} {SubsystemName} {UnitName} Implementation is Correct" - **Scope**: Complete unit review including all artifacts @@ -197,11 +205,10 @@ Reviews individual software unit implementation: - Tests (C# example): `test/{SystemName}.Tests[/{SubsystemName}...]/{UnitName}Tests.cs` - Source (snake_case C++ example): `src/{system_name}[/{subsystem_name}...]/{unit_name}.cpp` - Tests (snake_case C++ example): `test/{system_name}_tests[/{subsystem_name}...]/{unit_name}_tests.cpp` +- **Context Files**: Parent system design + requirements; add subsystem design + requirements for each subsystem level above the unit. ## `OTS-{OtsName}` Review (one per OTS item) -Reviews OTS item integration design, requirements, and verification evidence: - - **Purpose**: Proves that the OTS item provides the required functionality and is correctly integrated - **Title**: "Review that {OtsName} Provides Required Functionality" - **Scope**: No local source code; review covers integration design, requirements, and verification evidence @@ -214,8 +221,6 @@ Reviews OTS item integration design, requirements, and verification evidence: ## `Shared-{PackageName}` Review (one per Shared Package) -Reviews Shared Package integration design, requirements, and verification evidence: - - **Purpose**: Proves that the Shared Package provides the required advertised features and is correctly integrated - **Title**: "Review that {PackageName} Provides Required Features" - **Scope**: No local source code; review covers integration design, requirements, and verification evidence @@ -229,10 +234,9 @@ extensions (`.cs`, `.cpp`/`.hpp`, `.py`, etc.). Adapt to your repository's langu # Quality Checks -Before submitting ReviewMark configuration, verify: - - [ ] `.reviewmark.yaml` exists at repository root with proper structure - [ ] Review-set organization follows the standard hierarchy patterns - [ ] Each review-set focuses on a single compliance question (single focus principle) - [ ] File patterns use correct glob syntax and match intended files - [ ] Review-set file counts remain manageable (context management principle) +- [ ] Context configured per the Context Files section diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 71316a2..84c99fb 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -1,6 +1,5 @@ --- # ReviewMark Configuration File - # Files needing review needs-review: - README.md # Root README file @@ -15,20 +14,18 @@ needs-review: - '!**/bin/**' # Exclude build output - '!**/generated/**' # Exclude generated output - '!**/VhdlAst/Antlr/**' # Exclude ANTLR4-generated lexer/parser files - # Source of review evidence evidence-source: type: none - # Context included for all reviews. context: - docs/design/introduction.md # Software structure overview — shown to every reviewer - # Reviews reviews: # Purpose - id: Purpose title: Review that README and User Guide are Coherent and Complete + context: ['!docs/design/introduction.md'] # Exclude design intro — design should not back-feed into user docs review paths: - README.md - docs/user_guide/**/*.md @@ -36,9 +33,7 @@ reviews: # Decomposition - id: Decomposition title: Review that ApiMark Decomposition Addresses the Stated Purpose - context: - - README.md - - docs/user_guide/**/*.md + context: [README.md, docs/user_guide/**/*.md] paths: - requirements.yaml - docs/design/introduction.md @@ -46,9 +41,7 @@ reviews: # ApiMarkCore - Specials - id: ApiMark-Core-Architecture title: Review that ApiMark Core Architecture Satisfies Requirements - context: - - README.md - - docs/user_guide/**/*.md + context: [README.md, docs/user_guide/**/*.md] paths: - docs/reqstream/api-mark-core.yaml - docs/design/introduction.md @@ -60,21 +53,19 @@ reviews: context: - docs/design/api-mark-core.md - docs/reqstream/api-mark-core.yaml - paths: - - docs/reqstream/api-mark-core/**/*.yaml + paths: [docs/reqstream/api-mark-core/**/*.yaml] - id: ApiMark-Core-Design title: Review that ApiMark Core Design is Consistent and Complete - context: - - docs/reqstream/api-mark-core.yaml + context: [docs/reqstream/api-mark-core.yaml] paths: - docs/design/ots.md - docs/design/api-mark-core.md - docs/design/api-mark-core/**/*.md - id: ApiMark-Core-Verification title: Review that ApiMark Core Verification is Consistent and Complete - context: - - docs/reqstream/api-mark-core.yaml + context: [docs/reqstream/api-mark-core.yaml] paths: + - docs/verification/introduction.md - docs/verification/ots.md - docs/verification/api-mark-core.md - docs/verification/api-mark-core/**/*.md @@ -141,6 +132,9 @@ reviews: - docs/reqstream/api-mark-core/i-markdown-writer.yaml - docs/design/api-mark-core/i-markdown-writer.md - docs/verification/api-mark-core/i-markdown-writer.md + - docs/reqstream/api-mark-core/file-markdown-writer.yaml + - docs/design/api-mark-core/file-markdown-writer.md + - docs/verification/api-mark-core/file-markdown-writer.md - src/ApiMark.Core/IMarkdownWriter.cs - src/ApiMark.Core/FileMarkdownWriter.cs - test/ApiMark.Core.Tests/IMarkdownWriterTests.cs @@ -154,6 +148,9 @@ reviews: - docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml - docs/design/api-mark-core/i-markdown-writer-factory.md - docs/verification/api-mark-core/i-markdown-writer-factory.md + - docs/reqstream/api-mark-core/file-markdown-writer-factory.yaml + - docs/design/api-mark-core/file-markdown-writer-factory.md + - docs/verification/api-mark-core/file-markdown-writer-factory.md - src/ApiMark.Core/IMarkdownWriterFactory.cs - src/ApiMark.Core/FileMarkdownWriterFactory.cs - test/ApiMark.Core.Tests/IMarkdownWriterFactoryTests.cs @@ -184,9 +181,7 @@ reviews: # ApiMarkDotNet - Specials - id: ApiMark-DotNet-Architecture title: Review that ApiMark DotNet Architecture Satisfies Requirements - context: - - README.md - - docs/user_guide/**/*.md + context: [README.md, docs/user_guide/**/*.md] paths: - docs/reqstream/api-mark-dot-net.yaml - docs/design/introduction.md @@ -198,20 +193,17 @@ reviews: context: - docs/design/api-mark-dot-net.md - docs/reqstream/api-mark-dot-net.yaml - paths: - - docs/reqstream/api-mark-dot-net/**/*.yaml + paths: [docs/reqstream/api-mark-dot-net/**/*.yaml] - id: ApiMark-DotNet-Design title: Review that ApiMark DotNet Design is Consistent and Complete - context: - - docs/reqstream/api-mark-dot-net.yaml + context: [docs/reqstream/api-mark-dot-net.yaml] paths: - docs/design/ots.md - docs/design/api-mark-dot-net.md - docs/design/api-mark-dot-net/**/*.md - id: ApiMark-DotNet-Verification title: Review that ApiMark DotNet Verification is Consistent and Complete - context: - - docs/reqstream/api-mark-dot-net.yaml + context: [docs/reqstream/api-mark-dot-net.yaml] paths: - docs/verification/ots.md - docs/verification/api-mark-dot-net.md @@ -317,9 +309,7 @@ reviews: # ApiMarkCpp - Specials - id: ApiMark-Cpp-Architecture title: Review that ApiMark Cpp Architecture Satisfies Requirements - context: - - README.md - - docs/user_guide/**/*.md + context: [README.md, docs/user_guide/**/*.md] paths: - docs/reqstream/api-mark-cpp.yaml - docs/design/introduction.md @@ -328,23 +318,18 @@ reviews: - docs/verification/api-mark-cpp.md - id: ApiMark-Cpp-AllRequirements title: Review that All ApiMark Cpp Requirements are Complete - context: - - docs/design/api-mark-cpp.md - - docs/reqstream/api-mark-cpp.yaml - paths: - - docs/reqstream/api-mark-cpp/**/*.yaml + context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] + paths: [docs/reqstream/api-mark-cpp/**/*.yaml] - id: ApiMark-Cpp-Design title: Review that ApiMark Cpp Design is Consistent and Complete - context: - - docs/reqstream/api-mark-cpp.yaml + context: [docs/reqstream/api-mark-cpp.yaml] paths: - docs/design/ots.md - docs/design/api-mark-cpp.md - docs/design/api-mark-cpp/**/*.md - id: ApiMark-Cpp-Verification title: Review that ApiMark Cpp Verification is Consistent and Complete - context: - - docs/reqstream/api-mark-cpp.yaml + context: [docs/reqstream/api-mark-cpp.yaml] paths: - docs/verification/ots.md - docs/verification/api-mark-cpp.md @@ -353,9 +338,7 @@ reviews: # ApiMarkCpp - Units - id: ApiMark-Cpp-CppGenerator title: Review that ApiMark Cpp CppGenerator Implementation is Correct - context: - - docs/design/api-mark-cpp.md - - docs/reqstream/api-mark-cpp.yaml + context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] paths: - docs/reqstream/api-mark-cpp/cpp-generator.yaml - docs/design/api-mark-cpp/cpp-generator.md @@ -368,9 +351,7 @@ reviews: - test/ApiMark.Cpp.Tests/FixturePaths.cs - id: ApiMark-Cpp-CppEmitter title: Review that ApiMark Cpp CppEmitter Implementation is Correct - context: - - docs/design/api-mark-cpp.md - - docs/reqstream/api-mark-cpp.yaml + context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] paths: - docs/reqstream/api-mark-cpp/cpp-emitter.yaml - docs/design/api-mark-cpp/cpp-emitter.md @@ -380,9 +361,7 @@ reviews: - test/ApiMark.Cpp.Tests/CppEmitterTests.cs - id: ApiMark-Cpp-CppEmitterGradualDisclosure title: Review that ApiMark Cpp CppEmitterGradualDisclosure Implementation is Correct - context: - - docs/design/api-mark-cpp.md - - docs/reqstream/api-mark-cpp.yaml + context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] paths: - docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml - docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -392,9 +371,7 @@ reviews: - test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs - id: ApiMark-Cpp-CppEmitterSingleFile title: Review that ApiMark Cpp CppEmitterSingleFile Implementation is Correct - context: - - docs/design/api-mark-cpp.md - - docs/reqstream/api-mark-cpp.yaml + context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] paths: - docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml - docs/design/api-mark-cpp/cpp-emitter-single-file.md @@ -403,9 +380,7 @@ reviews: - test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs - id: ApiMark-Cpp-CppTypeLinkResolver title: Review that ApiMark Cpp CppTypeLinkResolver Implementation is Correct - context: - - docs/design/api-mark-cpp.md - - docs/reqstream/api-mark-cpp.yaml + context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] paths: - docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml - docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -416,9 +391,7 @@ reviews: # ApiMarkCpp.CppAst - Units - id: ApiMark-Cpp-CppAstModel title: Review that ApiMark Cpp CppAstModel Implementation is Correct - context: - - docs/design/api-mark-cpp.md - - docs/reqstream/api-mark-cpp.yaml + context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] paths: - docs/reqstream/api-mark-cpp/cpp-ast-model.yaml - docs/design/api-mark-cpp/cpp-ast-model.md @@ -427,9 +400,7 @@ reviews: - test/ApiMark.Cpp.Tests/CppAstModelTests.cs - id: ApiMark-Cpp-ClangAstParser title: Review that ApiMark Cpp ClangAstParser Implementation is Correct - context: - - docs/design/api-mark-cpp.md - - docs/reqstream/api-mark-cpp.yaml + context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] paths: - docs/reqstream/api-mark-cpp/clang-ast-parser.yaml - docs/design/api-mark-cpp/clang-ast-parser.md @@ -441,9 +412,7 @@ reviews: # ApiMarkVhdl - Specials - id: ApiMark-Vhdl-Architecture title: Review that ApiMark Vhdl Architecture Satisfies Requirements - context: - - README.md - - docs/user_guide/**/*.md + context: [README.md, docs/user_guide/**/*.md] paths: - docs/reqstream/api-mark-vhdl.yaml - docs/design/introduction.md @@ -455,20 +424,17 @@ reviews: context: - docs/design/api-mark-vhdl.md - docs/reqstream/api-mark-vhdl.yaml - paths: - - docs/reqstream/api-mark-vhdl/**/*.yaml + paths: [docs/reqstream/api-mark-vhdl/**/*.yaml] - id: ApiMark-Vhdl-Design title: Review that ApiMark Vhdl Design is Consistent and Complete - context: - - docs/reqstream/api-mark-vhdl.yaml + context: [docs/reqstream/api-mark-vhdl.yaml] paths: - docs/design/ots.md - docs/design/api-mark-vhdl.md - docs/design/api-mark-vhdl/**/*.md - id: ApiMark-Vhdl-Verification title: Review that ApiMark Vhdl Verification is Consistent and Complete - context: - - docs/reqstream/api-mark-vhdl.yaml + context: [docs/reqstream/api-mark-vhdl.yaml] paths: - docs/verification/ots.md - docs/verification/api-mark-vhdl.md @@ -553,9 +519,7 @@ reviews: # ApiMarkMSBuild - Specials - id: ApiMark-MSBuild-Architecture title: Review that ApiMark MSBuild Architecture Satisfies Requirements - context: - - README.md - - docs/user_guide/**/*.md + context: [README.md, docs/user_guide/**/*.md] paths: - docs/reqstream/api-mark-msbuild.yaml - docs/design/introduction.md @@ -567,20 +531,17 @@ reviews: context: - docs/design/api-mark-msbuild.md - docs/reqstream/api-mark-msbuild.yaml - paths: - - docs/reqstream/api-mark-msbuild/**/*.yaml + paths: [docs/reqstream/api-mark-msbuild/**/*.yaml] - id: ApiMark-MSBuild-Design title: Review that ApiMark MSBuild Design is Consistent and Complete - context: - - docs/reqstream/api-mark-msbuild.yaml + context: [docs/reqstream/api-mark-msbuild.yaml] paths: - docs/design/ots.md - docs/design/api-mark-msbuild.md - docs/design/api-mark-msbuild/**/*.md - id: ApiMark-MSBuild-Verification title: Review that ApiMark MSBuild Verification is Consistent and Complete - context: - - docs/reqstream/api-mark-msbuild.yaml + context: [docs/reqstream/api-mark-msbuild.yaml] paths: - docs/verification/ots.md - docs/verification/api-mark-msbuild.md @@ -605,9 +566,7 @@ reviews: # ApiMarkTool - Specials - id: ApiMark-Tool-Architecture title: Review that ApiMark Tool Architecture Satisfies Requirements - context: - - README.md - - docs/user_guide/**/*.md + context: [README.md, docs/user_guide/**/*.md] paths: - docs/reqstream/api-mark-tool.yaml - docs/design/introduction.md @@ -619,20 +578,17 @@ reviews: context: - docs/design/api-mark-tool.md - docs/reqstream/api-mark-tool.yaml - paths: - - docs/reqstream/api-mark-tool/**/*.yaml + paths: [docs/reqstream/api-mark-tool/**/*.yaml] - id: ApiMark-Tool-Design title: Review that ApiMark Tool Design is Consistent and Complete - context: - - docs/reqstream/api-mark-tool.yaml + context: [docs/reqstream/api-mark-tool.yaml] paths: - docs/design/ots.md - docs/design/api-mark-tool.md - docs/design/api-mark-tool/**/*.md - id: ApiMark-Tool-Verification title: Review that ApiMark Tool Verification is Consistent and Complete - context: - - docs/reqstream/api-mark-tool.yaml + context: [docs/reqstream/api-mark-tool.yaml] paths: - docs/verification/ots.md - docs/verification/api-mark-tool.md diff --git a/README.md b/README.md index 6e137d0..de89eab 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ type page — consuming only as much context as the task requires. C++ documentation generation requires `clang` to be installed and available: - **Windows**: Install [LLVM](https://releases.llvm.org/) or the "C++ Clang tools for Windows" - component via the Visual Studio Installer. The `ClangPath` MSBuild property or `--clang-path` + component via the Visual Studio Installer. The `ApiMarkClangPath` MSBuild property or `--clang-path` CLI option can point to a specific installation. - **macOS**: Xcode Command Line Tools (`xcode-select --install`) — `clang` is included. - **Linux**: Install via the system package manager (e.g. `apt install clang` or `dnf install clang`). @@ -157,6 +157,7 @@ Documentation is generated automatically after every build. Output goes to ``` See the [User Guide](https://github.com/DemaConsulting/ApiMark/releases) for the full list of properties including C++-specific options. +(The user guide is published as a release artifact so it is accessible from the NuGet package page, which has no access to repository files.) ## Building @@ -166,7 +167,7 @@ pwsh ./build.ps1 ## User Guide -The ApiMark User Guide is available on the [ApiMark releases page](https://github.com/DemaConsulting/ApiMark/releases). +The ApiMark User Guide is available on the [ApiMark releases page](https://github.com/DemaConsulting/ApiMark/releases) (published as a release artifact — the guide is not bundled in the NuGet package). ## Contributing diff --git a/docs/design/api-mark-core.md b/docs/design/api-mark-core.md index c6f2338..87a9fdc 100644 --- a/docs/design/api-mark-core.md +++ b/docs/design/api-mark-core.md @@ -46,8 +46,8 @@ and ApiMarkVhdl to discover source files using glob patterns. symbol data for a configured software component. - *Type*: In-process .NET public API. -- *Role*: Provider — ApiMarkCore publishes this interface; ApiMarkDotNet and - ApiMarkCpp implement it; ApiMarkTool consumes it directly. ApiMarkMsbuild +- *Role*: Provider — ApiMarkCore publishes this interface; ApiMarkDotNet, + ApiMarkCpp, and ApiMarkVhdl implement it; ApiMarkTool consumes it directly. ApiMarkMsbuild spawns ApiMarkTool as a child process and does not call this interface in-process. - *Contract*: `IApiEmitter Parse(IContext context)` — parses the configured @@ -146,12 +146,13 @@ files on the filesystem using gitignore-style glob patterns. ## Dependencies -N/A — ApiMarkCore has no dependencies on other systems, OTS items, or shared -packages. +ApiMarkCore depends on `Microsoft.Extensions.FileSystemGlobbing` (OTS) via +`GlobFileCollector`. See `docs/design/ots/file-system-globbing.md` for the +integration and usage design of this OTS item. ## Risk Control Measures -N/A — not a safety-classified software item. +N/A - not a safety-classified software item. ## Data Flow @@ -169,7 +170,7 @@ flow is: ## Design Constraints -- Platform: targets .NET 8 as a class library; no platform-specific code. +- Platform: targets a supported .NET LTS release as a class library; no platform-specific code. - No in-memory document model: Core defines only interfaces and their file-system implementations; language-specific generators own all in-memory state. - Stable API surface: changes to IApiGenerator, IApiEmitter, EmitConfig, diff --git a/docs/design/api-mark-core/emit-config.md b/docs/design/api-mark-core/emit-config.md index b1ad9c4..df954d6 100644 --- a/docs/design/api-mark-core/emit-config.md +++ b/docs/design/api-mark-core/emit-config.md @@ -27,14 +27,11 @@ the emitted Markdown. Default: `OutputFormat.GradualDisclosure`. Values: - `SingleFile` — all content written into a single `api.md` file using offset heading levels. -**EmitConfig.HeadingDepth**: `int` — the absolute heading level used for the -top-level section in single-file output. Default: `1`. Valid range: 1–3 (a -documented guideline; no runtime enforcement is performed by `EmitConfig` -itself — enforcement is the caller's responsibility). Ignored by -`GradualDisclosure` emitters. The effective member heading level is -`HeadingDepth + 3`; at depth 3 members are at H6, which is the maximum heading -level supported by Markdown. The CLI rejects depth values above 3 when -`--format single-file` is used. +**EmitConfig.HeadingDepth** defaults to `1` and is validated in the range 1–3 at +init time. Valid range: 1–3. The effective member heading level is `HeadingDepth + 3`; +at depth 3 members are at H6, which is the maximum heading level supported by Markdown. +Values outside 1–3 throw `ArgumentOutOfRangeException`. Ignored by `GradualDisclosure` +emitters. **OutputFormat**: `enum` — discriminates between the two supported output strategies. @@ -44,19 +41,21 @@ strategies. ### Key Methods -N/A — EmitConfig is a data object; it has no significant methods. `EmitConfig` +N/A - EmitConfig is a data object; it has no significant methods. `EmitConfig` uses reference equality (standard `sealed class` behavior); `init` properties do not affect equality semantics. ### Error Handling -N/A — EmitConfig is a passive data object. Validation of property values -(e.g., `HeadingDepth` range) is the responsibility of the caller or the -`IApiEmitter` implementation. +`EmitConfig` enforces the `HeadingDepth` range at init time. Setting `HeadingDepth` +to a value less than 1 throws `ArgumentOutOfRangeException` (via +`ArgumentOutOfRangeException.ThrowIfLessThan`). Setting it greater than 3 throws +`ArgumentOutOfRangeException` (via `ArgumentOutOfRangeException.ThrowIfGreaterThan`). +`Format` accepts any `OutputFormat` enum value and performs no range check. ### Dependencies -N/A — EmitConfig and OutputFormat are self-contained value types defined in +N/A - EmitConfig and OutputFormat are self-contained value types defined in ApiMarkCore; they have no dependencies on other units, OTS items, or shared packages. diff --git a/docs/design/api-mark-core/file-markdown-writer-factory.md b/docs/design/api-mark-core/file-markdown-writer-factory.md new file mode 100644 index 0000000..8f950d9 --- /dev/null +++ b/docs/design/api-mark-core/file-markdown-writer-factory.md @@ -0,0 +1,84 @@ +## FileMarkdownWriterFactory + + + +### Purpose + +FileMarkdownWriterFactory is the production file-system implementation of +`IMarkdownWriterFactory`. It creates Markdown output files under a configured +root directory, building the required directory tree on demand so that language +generators never need to manage physical folders themselves. + +### Data Model + +`FileMarkdownWriterFactory` is a `public sealed class` with one private field: + +- `_outputDirectory` (`string`, private readonly): the root output directory + supplied at construction time. Validated at construction; never null or whitespace. + +### Key Methods + +**FileMarkdownWriterFactory(string outputDirectory)**: Constructs the factory. + +- *Parameters*: `string outputDirectory` — absolute or relative path to the root + output directory. The directory is created on first use if it does not already + exist. Must not be null, empty, or whitespace. +- *Preconditions*: `outputDirectory` is not null, empty, or whitespace. +- *Postconditions*: `_outputDirectory` is set to the supplied value. +- *Throws*: `ArgumentException` when `outputDirectory` is null, empty, or whitespace. + +**FileMarkdownWriterFactory.CreateMarkdown(string subFolder, string name)**: +Creates a new `FileMarkdownWriter` targeting `{outputDirectory}/{subFolder}/{name}.md`. + +- *Parameters*: + - `string subFolder` — subfolder path relative to the output root. Pass an + empty string or whitespace to create a file directly under the output root. + Forward slashes are acceptable; path separators are normalized internally. + - `string name` — file name without the `.md` extension. Must not be null, + empty, or whitespace. +- *Returns*: A new `FileMarkdownWriter` ready for write calls. The caller is + responsible for disposing the returned writer. +- *Preconditions*: `name` is not null, empty, or whitespace. +- *Algorithm*: + 1. Reject `name` if null or whitespace; throw `ArgumentException`. + 2. Compute the target directory: when `subFolder` is null or whitespace, use + `_outputDirectory` directly; otherwise call `PathHelpers.SafePathCombine` + to combine `_outputDirectory` with `subFolder`. + 3. Call `Directory.CreateDirectory(targetDirectory)` to ensure the directory + tree exists before writing. + 4. Compose the full file path by calling `PathHelpers.SafePathCombine` with + `targetDirectory` and `name + ".md"`. + 5. Construct a `StreamWriter` over the file path using `UTF8Encoding` with + `encoderShouldEmitUTF8Identifier: false` (UTF-8 without BOM) and `append: false`. + 6. Wrap the stream writer in a new `FileMarkdownWriter` and return it. +- *Throws*: `ArgumentException` when `name` is null, empty, or whitespace. + +### Error Handling + +- Constructor throws `ArgumentException` immediately when `outputDirectory` is + null, empty, or whitespace, so callers receive a clear error at configuration + time rather than a deferred I/O failure. +- `CreateMarkdown` throws `ArgumentException` when `name` is null, empty, or + whitespace so the misuse is immediately attributable. +- `PathHelpers.SafePathCombine` is used for both the subfolder and the file-name + combination. It rejects path segments that resolve outside the base directory, + preventing directory-traversal vulnerabilities. +- `Directory.CreateDirectory` is idempotent and does not throw when the directory + already exists. +- I/O exceptions from `StreamWriter` construction (e.g., access-denied) propagate + to the caller unchanged. + +### Dependencies + +- **PathHelpers** — `SafePathCombine` is used to combine the output root with the + caller-supplied subFolder and the file name, preventing directory-traversal attacks. + +### Callers + +- **DotNetGenerator** — injects `FileMarkdownWriterFactory` as the `IMarkdownWriterFactory` + in production; a test double is substituted in unit tests. +- **CppGenerator** — same injection pattern as DotNetGenerator. +- **VhdlGenerator** — same injection pattern as DotNetGenerator. +- **Program** — constructs `FileMarkdownWriterFactory` from the configured output + directory and passes it to the language generator. diff --git a/docs/design/api-mark-core/file-markdown-writer.md b/docs/design/api-mark-core/file-markdown-writer.md new file mode 100644 index 0000000..907d04a --- /dev/null +++ b/docs/design/api-mark-core/file-markdown-writer.md @@ -0,0 +1,108 @@ +## FileMarkdownWriter + + + +### Purpose + +FileMarkdownWriter is the production file-system implementation of `IMarkdownWriter`. +It writes Markdown content to an underlying `StreamWriter` opened over an output file. +Instances are created exclusively by `FileMarkdownWriterFactory` and are not directly +constructable by external callers. Each instance owns its `StreamWriter` and flushes +and closes it on disposal. + +### Data Model + +`FileMarkdownWriter` is an `internal sealed class` with two private fields: + +- `_writer` (`StreamWriter`, private readonly): the underlying stream writer this + instance writes Markdown content to. Ownership is transferred at construction; + the writer is disposed when this instance is disposed. +- `_disposed` (`bool`, private): tracks whether `Dispose` has been called, to + prevent double-disposal and to enforce the use-after-dispose contract. + +### Key Methods + +**FileMarkdownWriter.WriteHeading(int level, string text)**: Writes an ATX Markdown +heading at the specified level. + +- *Parameters*: `int level` — heading level in the range 1–6. `string text` — heading + text to display. +- *Algorithm*: Writes a string of `level` `#` characters followed by a space and + `text`, then emits a blank line to separate the heading from the next block. +- *Throws*: `ObjectDisposedException` if disposed. `ArgumentOutOfRangeException` + if `level` is less than 1 or greater than 6. + +**FileMarkdownWriter.WriteSignature(string language, string code)**: Writes a fenced +code block representing an API signature. + +- *Parameters*: `string language` — language identifier for the fence label. + `string code` — signature text. +- *Algorithm*: Writes `` ``` ``language``, then `code`, then ` ``` `, then a blank line. +- *Throws*: `ObjectDisposedException` if disposed. + +**FileMarkdownWriter.WriteParagraph(string text)**: Writes a prose paragraph. + +- *Parameters*: `string text` — paragraph body. +- *Algorithm*: Writes `text` followed by a blank line to close the Markdown paragraph. +- *Throws*: `ObjectDisposedException` if disposed. + +**FileMarkdownWriter.WriteTable(string[] headers, IEnumerable rows)**: Writes +a pipe-delimited GFM table. + +- *Parameters*: `string[] headers` — column header labels. `IEnumerable rows` + — data rows; each row must have the same number of cells as `headers`. +- *Algorithm*: Writes the header row, a separator row of `---` cells, and each data row, + all using pipe delimiters. Pipe characters in header and cell values are escaped as `\|`. + Emits a blank line after the last row. +- *Throws*: `ObjectDisposedException` if disposed. + +**FileMarkdownWriter.WriteCodeBlock(string language, string code)**: Writes a fenced +code block containing a usage example. + +- *Algorithm*: Delegates to `WriteSignature` — the two methods produce identical output; + the distinction exists at the API level for semantic clarity. +- *Throws*: `ObjectDisposedException` if disposed. + +**FileMarkdownWriter.WriteLink(string text, string relativePath)**: Writes an inline +Markdown navigation link. + +- *Parameters*: `string text` — visible link label. `string relativePath` — relative + path to the target file, written verbatim into the link href. +- *Algorithm*: Writes `[text](relativePath)` followed by a blank line. +- *Throws*: `ObjectDisposedException` if disposed. + +**FileMarkdownWriter.Dispose()**: Flushes pending content and releases the underlying +`StreamWriter` and its file handle. + +- *Algorithm*: If `_disposed` is already true, returns immediately (no-op). Otherwise + sets `_disposed = true`, calls `_writer.Flush()`, and calls `_writer.Dispose()`. +- Safe to call multiple times; subsequent calls after the first are no-ops. + +### Error Handling + +- All write methods call `ObjectDisposedException.ThrowIf(_disposed, this)` at entry + so that use-after-dispose is detected immediately and attributed to the correct call site. +- `WriteHeading` validates the level range using `ArgumentOutOfRangeException.ThrowIfNegativeOrZero` + and `ArgumentOutOfRangeException.ThrowIfGreaterThan(level, 6)`. Values below 1 and above 6 are + not valid ATX heading levels in CommonMark. +- `WriteSignature`, `WriteParagraph`, `WriteCodeBlock`, and `WriteLink` call + `ArgumentNullException.ThrowIfNull` on every string parameter immediately after the + disposed guard. `WriteTable` guards both `headers` and `rows` with + `ArgumentNullException.ThrowIfNull`. Rejecting nulls at the API boundary surfaces + caller defects with a clear diagnostic rather than a deferred `NullReferenceException` + buried in rendering logic. +- `Dispose` guards against double-disposal by checking `_disposed` before touching + `_writer`, because `StreamWriter` is not resilient to multiple dispose calls. +- I/O exceptions from `_writer` writes propagate to the caller unchanged. + +### Dependencies + +N/A - FileMarkdownWriter depends only on `System.IO.StreamWriter`, which is a BCL type +and is not tracked as a separate software item. + +### Callers + +- **FileMarkdownWriterFactory** — the only creator of `FileMarkdownWriter` instances. + Constructs a `StreamWriter` configured for UTF-8 without BOM, wraps it in a new + `FileMarkdownWriter`, and returns it as `IMarkdownWriter` to the caller. diff --git a/docs/design/api-mark-core/glob-file-collector.md b/docs/design/api-mark-core/glob-file-collector.md index e2049f9..c0162b9 100644 --- a/docs/design/api-mark-core/glob-file-collector.md +++ b/docs/design/api-mark-core/glob-file-collector.md @@ -39,14 +39,21 @@ filesystem matching the specified patterns. - `string workingDirectory` — absolute path used as the root for relative patterns. - *Returns*: `IReadOnlyList` — sorted, deduplicated absolute file paths. -- *Preconditions*: none — empty patterns return an empty list; non-existent - roots are silently skipped. +- *Preconditions*: `patterns`, `languageExtensions`, and `workingDirectory` must + not be null; `ArgumentNullException` is thrown if any is null. Empty collections + and an empty working directory string are valid. - *Algorithm*: 1. For each pattern, strip a leading `!` (marks exclusion) and trim whitespace. 2. Call `ParsePattern` to obtain a `(root, globTail)` pair. Relative patterns use `workingDirectory` as root; absolute patterns derive their root from the longest non-glob path prefix (see `SplitAbsolutePattern`). - 3. Skip the pattern silently if `globTail` is empty or `root` does not exist. + 3. When `globTail` is empty (no glob metacharacters in the absolute pattern), + treat `root` as a literal file path: if the file exists and its extension + appears in `languageExtensions`, add/remove it directly without directory + traversal, then continue to the next pattern. Skip silently if `root` does + not exist as a file or its extension is not in `languageExtensions`. + When `globTail` is non-empty and `root` does not exist as a directory, + skip the pattern silently. 4. Call `HasBareStarFinalSegment` to determine whether extension inference applies (final segment is exactly `*`). 5. Run `Microsoft.Extensions.FileSystemGlobbing.Matcher.GetResultsInFullPath` @@ -78,7 +85,8 @@ non-glob path prefix and the first glob metacharacter. - Returns `(root, tail)` where root is the longest static prefix (a real directory) and tail is passed to `Matcher.AddInclude`. -- If no metacharacter is found, returns `(pattern, "")` — no glob tail. +- If no metacharacter is found, returns `(pattern, "")` — no glob tail; the + caller treats the entire pattern as a literal file path. - If no `/` precedes the first metacharacter, returns `("", pattern)` — no static root. @@ -88,9 +96,11 @@ with no extension), triggering language-extension filtering. ### Error Handling -`GlobFileCollector.Collect` never throws for missing directories or empty -pattern lists. Non-existent roots are skipped silently. All other exceptions -(e.g. I/O errors from `Matcher.GetResultsInFullPath`) propagate to the caller. +`GlobFileCollector.Collect` throws `ArgumentNullException` immediately when +`patterns`, `languageExtensions`, or `workingDirectory` is null. For non-null +inputs it never throws for missing directories or empty pattern lists; non-existent +roots are skipped silently. All other exceptions (e.g. I/O errors from +`Matcher.GetResultsInFullPath`) propagate to the caller. ### Dependencies diff --git a/docs/design/api-mark-core/i-api-emitter.md b/docs/design/api-mark-core/i-api-emitter.md index 6f483bb..1e0b2a0 100644 --- a/docs/design/api-mark-core/i-api-emitter.md +++ b/docs/design/api-mark-core/i-api-emitter.md @@ -15,9 +15,9 @@ emitted into multiple formats in one pass. ### Data Model -N/A — IApiEmitter is an interface with no fields or properties of its own. -Parsed symbol state is held by the private nested emitter class that each -language module returns from `IApiGenerator.Parse`. +N/A - IApiEmitter is an interface with no fields or properties of its own. +Implementations hold all data required to emit documentation and are returned by +`IApiGenerator.Parse`. ### Key Methods diff --git a/docs/design/api-mark-core/i-api-generator.md b/docs/design/api-mark-core/i-api-generator.md index 48a01cc..ed69f2a 100644 --- a/docs/design/api-mark-core/i-api-generator.md +++ b/docs/design/api-mark-core/i-api-generator.md @@ -19,7 +19,7 @@ multiple Emit calls. ### Data Model -N/A — IApiGenerator is an interface with no fields or properties of its own. +N/A - IApiGenerator is an interface with no fields or properties of its own. Language-specific options are passed at construction time of the implementing class and are not exposed through this interface. diff --git a/docs/design/api-mark-core/i-context.md b/docs/design/api-mark-core/i-context.md index c2aca0e..2c0ae9d 100644 --- a/docs/design/api-mark-core/i-context.md +++ b/docs/design/api-mark-core/i-context.md @@ -13,7 +13,7 @@ that route messages to a log file or build-system diagnostic channel. ### Data Model -N/A — IContext is an interface with no fields or properties of its own. State, +N/A - IContext is an interface with no fields or properties of its own. State, if any, is managed entirely by the implementing class. ### Key Methods @@ -44,7 +44,7 @@ throw in response to invalid arguments or I/O failures. ### Dependencies -N/A — IContext is an interface defined in ApiMarkCore; it has no dependencies +N/A - IContext is an interface defined in ApiMarkCore; it has no dependencies on other units, OTS items, or shared packages. ### Callers diff --git a/docs/design/api-mark-core/i-markdown-writer.md b/docs/design/api-mark-core/i-markdown-writer.md index ef43c21..5e08fc8 100644 --- a/docs/design/api-mark-core/i-markdown-writer.md +++ b/docs/design/api-mark-core/i-markdown-writer.md @@ -28,7 +28,8 @@ implementation, `FileMarkdownWriter`, holds: - *Parameters*: `int level` — heading depth (1–6); `string text` — heading text. - *Returns*: `void` -- *Preconditions*: `level` must be between 1 and 6 inclusive. +- *Preconditions*: `level` must be between 1 and 6 inclusive; `text` must not be + null or empty. - *Postconditions*: A Markdown heading line (`# text` through `###### text`) is appended to the current output file. @@ -37,14 +38,14 @@ implementation, `FileMarkdownWriter`, holds: - *Parameters*: `string language` — fence language tag (e.g. `csharp`); `string code` — the formatted signature text. - *Returns*: `void` -- *Preconditions*: N/A — no constraints on parameter values beyond the interface type. +- *Preconditions*: `language` and `code` must not be null. - *Postconditions*: A fenced code block is appended to the current output file. **IMarkdownWriter.WriteParagraph**: Writes a prose paragraph. - *Parameters*: `string text` — paragraph content. - *Returns*: `void` -- *Preconditions*: N/A — no constraints on parameter values beyond the interface type. +- *Preconditions*: `text` must not be null. - *Postconditions*: A blank-line-delimited paragraph is appended to the current output file. @@ -54,7 +55,9 @@ implementation, `FileMarkdownWriter`, holds: `IEnumerable rows` — row data, each inner array containing one cell per column. - *Returns*: `void` -- *Preconditions*: N/A — no constraints on parameter values beyond the interface type. +- *Preconditions*: `headers` must not be null and must contain at least one element; + `rows` must not be null; each row must contain the same number of elements as + `headers`; no element in `headers` or any row may be null. - *Postconditions*: A pipe-delimited Markdown table is appended to the current output file. Literal pipe characters (`|`) in cell content are escaped as `\|`. @@ -62,7 +65,7 @@ implementation, `FileMarkdownWriter`, holds: - *Parameters*: `string language` — fence language tag; `string code` — code text. - *Returns*: `void` -- *Preconditions*: N/A — no constraints on parameter values beyond the interface type. +- *Preconditions*: `language` and `code` must not be null. - *Postconditions*: A fenced code block is appended to the current output file. **IMarkdownWriter.WriteLink**: Writes a relative navigation link to another documentation file. @@ -70,7 +73,7 @@ implementation, `FileMarkdownWriter`, holds: - *Parameters*: `string text` — display label; `string relativePath` — path to the linked file relative to the current output file. - *Returns*: `void` -- *Preconditions*: N/A — no constraints on parameter values beyond the interface type. +- *Preconditions*: `text` must not be null or empty; `relativePath` must not be null. - *Postconditions*: A Markdown inline link of the form `[text](relativePath)` is appended to the current output file. @@ -84,6 +87,10 @@ output file. `IMarkdownWriter` itself defines no error-handling contract; it is an interface. +Implementations must throw `ArgumentNullException` or `ArgumentException` when +required preconditions are violated (null arguments, empty heading text, out-of-range +heading level, mismatched row length, or an empty headers array). + `FileMarkdownWriter` does not catch I/O errors. Any `IOException` raised by the underlying `StreamWriter` (for example, if the output directory is not writable or the disk is full) propagates directly to the caller. Callers are responsible for @@ -96,7 +103,7 @@ after `Dispose` throws `ObjectDisposedException`. ### Dependencies -N/A — IMarkdownWriter is an interface defined in ApiMarkCore; it has no dependencies +N/A - IMarkdownWriter is an interface defined in ApiMarkCore; it has no dependencies on other units, OTS items, or shared packages. ### Callers diff --git a/docs/design/api-mark-core/path-helpers.md b/docs/design/api-mark-core/path-helpers.md index a6cc91f..22c4152 100644 --- a/docs/design/api-mark-core/path-helpers.md +++ b/docs/design/api-mark-core/path-helpers.md @@ -21,8 +21,9 @@ is stateless and thread-safe. relative path segments. - *Parameters*: `string basePath` — trusted base directory. `params string[] relativePaths` - — one or more caller-supplied relative path segments appended in order. -- *Returns*: `string` — the combined path. + — zero or more caller-supplied relative path segments appended in order. +- *Returns*: `string` — the combined path. When no segments are supplied, returns + `basePath` unchanged. - *Preconditions*: `basePath` must not be null. `relativePaths` must not be null. Each segment must not be null. - *Postconditions*: The returned path is the result of joining all segments to `basePath` @@ -34,12 +35,14 @@ relative path segments. PathHelpers joins all segments using `Path.Join`, then applies a single escape check: `Path.GetFullPath` is called on both the base and the combined path, and -`Path.GetRelativePath` confirms the result still resolves under the base. If the -relative path starts with `..` or is itself rooted, the method throws `ArgumentException`. - -Segments may contain `..` components or be rooted provided the combined result does not -escape — for example segments `["baa", ".."]` on base `C:\foo` resolve back to `C:\foo` -and are accepted. Only the final resolved position matters. +`Path.GetRelativePath` confirms the result still resolves under the base. +`ArgumentException` is thrown when and only when the normalized combined path escapes +(resolves above) the base directory — that is, when the relative path from the base +to the combined result starts with `..` or is itself rooted. + +Individual segments may contain `..` components or be rooted provided the combined +result does not escape — for example segments `["baa", ".."]` on base `C:\foo` resolve +back to `C:\foo` and are accepted. Only the final resolved position matters. `Path.Join` is used rather than `Path.Combine` so no segment can silently replace the base path. diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index ee829ac..5731d02 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -97,7 +97,7 @@ MSBuild properties used to configure generation for C++ projects: ## Risk Control Measures -N/A — not a safety-classified software item. +N/A - not a safety-classified software item. ## Data Flow diff --git a/docs/design/api-mark-cpp/cpp-ast-model.md b/docs/design/api-mark-cpp/cpp-ast-model.md index c1a8161..914295b 100644 --- a/docs/design/api-mark-cpp/cpp-ast-model.md +++ b/docs/design/api-mark-cpp/cpp-ast-model.md @@ -34,24 +34,24 @@ The following record types are defined (all in `ApiMark.Cpp`): ### Key Methods -N/A — CppAstModel contains only record type definitions. All construction is +N/A - CppAstModel contains only record type definitions. All construction is performed by the C# record compiler-generated constructors. ### Error Handling -N/A — CppAstModel records perform no validation in their constructors; all +N/A - CppAstModel records perform no validation in their constructors; all values are accepted as provided. Validation is the responsibility of `ClangAstParser`, which constructs these records from verified JSON data. ### External Interfaces -N/A — CppAstModel records are purely in-process value types. They are +N/A - CppAstModel records are purely in-process value types. They are constructed exclusively by `ClangAstParser` and consumed by `CppEmitter` and `CppEmitterGradualDisclosure`. ### Dependencies -N/A — CppAstModel has no external dependencies. All types are self-contained +N/A - CppAstModel has no external dependencies. All types are self-contained immutable records with no constructor logic beyond field assignment. ### Callers diff --git a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md index e1422ba..2648069 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -85,7 +85,7 @@ page at `{nsKey}/operators.md`. ### Error Handling -N/A — CppEmitterGradualDisclosure propagates exceptions from the factory and +N/A - CppEmitterGradualDisclosure propagates exceptions from the factory and writer without wrapping. No additional error handling is performed. ### External Interfaces diff --git a/docs/design/api-mark-cpp/cpp-emitter-single-file.md b/docs/design/api-mark-cpp/cpp-emitter-single-file.md index c74a4c5..667055d 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/design/api-mark-cpp/cpp-emitter-single-file.md @@ -15,7 +15,7 @@ also omitted because it only applies to the multi-file layout. ### Data Model -N/A — CppEmitterSingleFile holds no data model of its own. It reads from the +N/A - CppEmitterSingleFile holds no data model of its own. It reads from the `SortedDictionary` supplied by `CppEmitter` and from `CppGeneratorOptions` via the parent emitter. @@ -67,7 +67,7 @@ display name. ### Error Handling -N/A — CppEmitterSingleFile propagates exceptions from the factory and writer +N/A - CppEmitterSingleFile propagates exceptions from the factory and writer without wrapping. No additional error handling is performed. ### External Interfaces diff --git a/docs/design/api-mark-cpp/cpp-type-link-resolver.md b/docs/design/api-mark-cpp/cpp-type-link-resolver.md index 1d0836e..8528999 100644 --- a/docs/design/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -65,18 +65,18 @@ C++ name (everything before the last `::`), or an empty string for unqualified n ### Error Handling -N/A — CppTypeLinkResolver performs no I/O and throws no exceptions under normal +N/A - CppTypeLinkResolver performs no I/O and throws no exceptions under normal operation. Null or whitespace type strings are returned unchanged without error. ### External Interfaces -N/A — CppTypeLinkResolver is an in-process utility class with no external +N/A - CppTypeLinkResolver is an in-process utility class with no external dependencies or outbound interfaces. All input is supplied via constructor and method parameters. ### Dependencies -N/A — CppTypeLinkResolver depends only on the BCL (`System.IO.Path`, +N/A - CppTypeLinkResolver depends only on the BCL (`System.IO.Path`, `System.Collections.Generic`). It has no dependency on ApiMarkCore or other ApiMark units. diff --git a/docs/design/api-mark-dot-net.md b/docs/design/api-mark-dot-net.md index 8832b76..697c46a 100644 --- a/docs/design/api-mark-dot-net.md +++ b/docs/design/api-mark-dot-net.md @@ -100,7 +100,7 @@ messages. ## Risk Control Measures -N/A — not a safety-classified software item. +N/A - not a safety-classified software item. ## Data Flow diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md b/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md index 0f4e4de..4e34077 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md @@ -66,7 +66,7 @@ this class. ### External Interfaces -N/A — DotNetEmitterSingleFile is an internal class with no external interfaces. +N/A - DotNetEmitterSingleFile is an internal class with no external interfaces. ### Callers diff --git a/docs/design/api-mark-dot-net/type-name-simplifier.md b/docs/design/api-mark-dot-net/type-name-simplifier.md index 0a945c2..da84308 100644 --- a/docs/design/api-mark-dot-net/type-name-simplifier.md +++ b/docs/design/api-mark-dot-net/type-name-simplifier.md @@ -12,7 +12,7 @@ matches the naming conventions a C# developer would write by hand. ### Data Model -N/A — TypeNameSimplifier is a stateless helper class. It exposes a single +N/A - TypeNameSimplifier is a stateless helper class. It exposes a single simplification method and holds no mutable state across calls. **Primitives** (`private static readonly Dictionary`): maps full diff --git a/docs/design/api-mark-msbuild.md b/docs/design/api-mark-msbuild.md index 1d59bc1..5ae5973 100644 --- a/docs/design/api-mark-msbuild.md +++ b/docs/design/api-mark-msbuild.md @@ -96,7 +96,7 @@ executable to perform generation. ## Risk Control Measures -N/A — not a safety-classified software item. +N/A - not a safety-classified software item. ## Data Flow diff --git a/docs/design/api-mark-msbuild/api-mark-task.md b/docs/design/api-mark-msbuild/api-mark-task.md index 832e31e..9849b1d 100644 --- a/docs/design/api-mark-msbuild/api-mark-task.md +++ b/docs/design/api-mark-msbuild/api-mark-task.md @@ -201,5 +201,5 @@ the task returns true silently with no side effects. ### Callers -N/A — entry point, called by the MSBuild host environment via the task execution +N/A - entry point, called by the MSBuild host environment via the task execution pipeline when the `AfterTargets="Build"` target fires. diff --git a/docs/design/api-mark-tool.md b/docs/design/api-mark-tool.md index a853c3a..28e26c8 100644 --- a/docs/design/api-mark-tool.md +++ b/docs/design/api-mark-tool.md @@ -83,7 +83,7 @@ implementations from ApiMarkCore and dispatches to them using the two-stage pipe ## Risk Control Measures -N/A — not a safety-classified software item. +N/A - not a safety-classified software item. ## Data Flow diff --git a/docs/design/api-mark-tool/cli.md b/docs/design/api-mark-tool/cli.md index fdb24a1..22121ea 100644 --- a/docs/design/api-mark-tool/cli.md +++ b/docs/design/api-mark-tool/cli.md @@ -32,7 +32,7 @@ can be tested independently. **Consumed**: -N/A — The Cli subsystem has no dependencies beyond the .NET runtime. +N/A - The Cli subsystem has no dependencies beyond the .NET runtime. ### Design diff --git a/docs/design/api-mark-tool/cli/context.md b/docs/design/api-mark-tool/cli/context.md index e90fdb1..71cf492 100644 --- a/docs/design/api-mark-tool/cli/context.md +++ b/docs/design/api-mark-tool/cli/context.md @@ -100,7 +100,7 @@ to stderr (in red) and to the log file. #### Dependencies -N/A — Context depends only on the .NET runtime (`System.IO.StreamWriter`, +N/A - Context depends only on the .NET runtime (`System.IO.StreamWriter`, `System.Console`). It has no dependencies on other units, OTS items, or shared packages. diff --git a/docs/design/api-mark-tool/program.md b/docs/design/api-mark-tool/program.md index c8f57e2..90ca1a4 100644 --- a/docs/design/api-mark-tool/program.md +++ b/docs/design/api-mark-tool/program.md @@ -127,5 +127,5 @@ re-thrown. ### Callers -N/A — entry point, called by the host environment (ApiMarkTask process spawn, +N/A - entry point, called by the host environment (ApiMarkTask process spawn, dotnet tool invocation, or CI pipeline script). diff --git a/docs/design/api-mark-tool/self-test/validation.md b/docs/design/api-mark-tool/self-test/validation.md index 78f015d..c2236a6 100644 --- a/docs/design/api-mark-tool/self-test/validation.md +++ b/docs/design/api-mark-tool/self-test/validation.md @@ -14,7 +14,7 @@ optionally written to a `.trx` or `.xml` file. #### Data Model -N/A — Validation is a static class with no instance state. It uses local +N/A - Validation is a static class with no instance state. It uses local variables within each method for test results and temporary file paths. All test result objects are `DemaConsulting.TestResults.TestResult` instances diff --git a/docs/design/api-mark-vhdl.md b/docs/design/api-mark-vhdl.md index e76f15e..1bda9d3 100644 --- a/docs/design/api-mark-vhdl.md +++ b/docs/design/api-mark-vhdl.md @@ -82,7 +82,7 @@ ANTLR4 parser to parse VHDL-2008 source files. ## Risk Control Measures -N/A — not a safety-classified software item. +N/A - not a safety-classified software item. ## Data Flow diff --git a/docs/design/api-mark-vhdl/vhdl-ast-model.md b/docs/design/api-mark-vhdl/vhdl-ast-model.md index 4e0d7ea..a3e285b 100644 --- a/docs/design/api-mark-vhdl/vhdl-ast-model.md +++ b/docs/design/api-mark-vhdl/vhdl-ast-model.md @@ -69,18 +69,18 @@ Design invariants: ### Key Methods -N/A — VhdlAstModel contains only immutable record definitions; it exposes no methods +N/A - VhdlAstModel contains only immutable record definitions; it exposes no methods beyond the C# positional-record auto-generated members (constructor, deconstruct, equality). ### Error Handling -N/A — VhdlAstModel is a pure data model with no runtime logic; error handling is +N/A - VhdlAstModel is a pure data model with no runtime logic; error handling is the responsibility of `VhdlAstParser` during record construction. ### Dependencies -N/A — VhdlAstModel has no dependencies on other units, OTS packages, or shared +N/A - VhdlAstModel has no dependencies on other units, OTS packages, or shared packages; it uses only BCL types (`IReadOnlyList`, `string`). ### Callers diff --git a/docs/design/api-mark-vhdl/vhdl-ast-parser.md b/docs/design/api-mark-vhdl/vhdl-ast-parser.md index 0f1eeeb..6e8b08f 100644 --- a/docs/design/api-mark-vhdl/vhdl-ast-parser.md +++ b/docs/design/api-mark-vhdl/vhdl-ast-parser.md @@ -11,7 +11,7 @@ the resulting parse tree to produce a `VhdlFileModel`. ### Data Model -N/A — VhdlAstParser is a stateless static class; all state is local to each `Parse` +N/A - VhdlAstParser is a stateless static class; all state is local to each `Parse` call and returned as a `VhdlFileModel`. ### Key Methods diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 3b43290..15868fe 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -10,9 +10,11 @@ systems: ApiMark.Core (shared contracts and file-path helpers), ApiMark.DotNet (VHDL language generator), ApiMark.MSBuild (unified MSBuild task that spawns ApiMark.Tool out-of-process), and ApiMark.Tool (the .NET executable invoked by ApiMarkTask and directly by users or CI pipelines). -Three OTS items provide library support: Mono.Cecil for the DotNet system, clang (via -`clang -ast-dump=json`) for the Cpp system, and DemaConsulting.TestResults for the SelfTest -subsystem. A fourth archived OTS item, cpp-ast-net, is retained for historical reference. +Several OTS items provide library support: Mono.Cecil for the DotNet system, clang (via +`clang -ast-dump=json`) for the Cpp system, DemaConsulting.TestResults for the SelfTest +subsystem, Antlr4.Runtime.Standard/ANTLR4 vhdl2008 grammar for the Vhdl system, and +Microsoft.Extensions.FileSystemGlobbing for glob-based file discovery in ApiMarkCore. +An archived OTS item, cpp-ast-net, is retained for historical reference. ## Purpose @@ -23,14 +25,8 @@ its requirements without reading source code. ## Scope -Local items: - -- **ApiMarkCore**: system and unit design. -- **ApiMarkDotNet**: system and unit design. -- **ApiMarkCpp**: system and unit design. -- **ApiMarkVhdl**: system and unit design. -- **ApiMarkMsbuild**: system and unit design. -- **ApiMarkTool**: system and unit design. +Local items cover system, subsystem, and unit design for: ApiMarkCore, ApiMarkDotNet, +ApiMarkCpp, ApiMarkVhdl, ApiMarkMsbuild, and ApiMarkTool. OTS items: @@ -38,10 +34,11 @@ OTS items: - **clang**: integration and usage design (via `clang -ast-dump=json`). - **DemaConsulting.TestResults**: integration and usage design. - **Antlr4.Runtime.Standard / ANTLR4 vhdl2008 grammar**: integration and usage design. +- **Microsoft.Extensions.FileSystemGlobbing**: integration and usage design. - **cpp-ast-net**: integration and usage design (archived; retained for historical reference). -Out of scope: test projects, build pipeline CI configuration, and the internal design -of OTS items. +Out of scope: design documents are not produced for test projects or build pipeline CI +configuration; the internal design of OTS items is also excluded. ## Software Structure @@ -100,6 +97,7 @@ OTS Dependencies: ├── DemaConsulting.TestResults (OTS) ├── clang -ast-dump=json (OTS) ├── Antlr4.Runtime.Standard / ANTLR4 vhdl2008 grammar (OTS) +├── Microsoft.Extensions.FileSystemGlobbing (OTS) └── cpp-ast-net (OTS) [archived] ``` @@ -158,20 +156,6 @@ src/ ├── SelfTest/ │ └── Validation.cs - self-validation tests for --validate └── Program.cs - dotnet CLI entry point dispatching to IApiGenerator - -test/ -├── ApiMark.Core.TestHelpers/ -│ ├── InMemoryMarkdownWriterFactory.cs - in-memory IMarkdownWriterFactory test double -│ ├── InMemoryMarkdownWriter.cs - in-memory IMarkdownWriter test double -│ └── InMemoryContext.cs - in-memory IContext test double -├── ApiMark.Core.Tests/ - unit tests for Core contracts -├── ApiMark.DotNet.Fixtures/ - multi-target fixture assembly for DotNet integration tests -├── ApiMark.DotNet.Tests/ - unit tests for DotNetGenerator and TypeNameSimplifier -├── ApiMark.Cpp.Fixtures/ - C++ fixture headers for CppGenerator integration tests -├── ApiMark.Cpp.Tests/ - unit tests for CppGenerator -├── ApiMark.Vhdl.Tests/ - unit tests for VhdlGenerator -├── ApiMark.MSBuild.Tests/ - unit tests for ApiMarkTask -└── ApiMark.Tool.Tests/ - integration tests for the CLI tool ``` ## Companion Artifact Structure @@ -261,8 +245,9 @@ Each local software item has corresponding artifacts in parallel directory trees - Source: `src/ApiMark.Core/`, `src/ApiMark.DotNet/`, `src/ApiMark.Cpp/`, `src/ApiMark.Vhdl/`, `src/ApiMark.MSBuild/`, `src/ApiMark.Tool/` - Tests: `test/ApiMark.Core.TestHelpers/`, `test/ApiMark.Core.Tests/`, `test/ApiMark.DotNet.Tests/`, - `test/ApiMark.Cpp.Fixtures/`, `test/ApiMark.Cpp.Tests/`, `test/ApiMark.Vhdl.Tests/`, + `test/ApiMark.Cpp.Tests/`, `test/ApiMark.Vhdl.Tests/`, `test/ApiMark.MSBuild.Tests/`, `test/ApiMark.Tool.Tests/` +- Fixtures: `test/ApiMark.DotNet.Fixtures/`, `test/ApiMark.Cpp.Fixtures/` OTS items have integration/usage design documentation parallel to system folders: @@ -288,6 +273,12 @@ And for ANTLR4: - Design: `docs/design/ots/antlr4.md` - Verification: `docs/verification/ots/antlr4.md` +And for Microsoft.Extensions.FileSystemGlobbing: + +- Requirements: `docs/reqstream/ots/file-system-globbing.yaml` +- Design: `docs/design/ots/file-system-globbing.md` +- Verification: `docs/verification/ots/file-system-globbing.md` + And for cpp-ast-net (archived): - Requirements: `docs/reqstream/ots/cpp-ast-net.yaml` @@ -298,5 +289,5 @@ Review-sets: defined in `.reviewmark.yaml` ## References -N/A — this document set is derived from repository source materials and does not +N/A - this document set is derived from repository source materials and does not introduce external specifications or standards requiring citation. diff --git a/docs/design/ots.md b/docs/design/ots.md index 188bdba..b41bd65 100644 --- a/docs/design/ots.md +++ b/docs/design/ots.md @@ -25,16 +25,22 @@ OTS version upgrades in ApiMark are managed as follows: sprint planning. - Major version changes trigger a design review to assess API compatibility and any impact on the integration pattern documented in this section. -- Reproducible builds are ensured by committing lock files (NuGet packages.lock.json) - and pinning versions in the project file. +- Reproducible builds are ensured by pinning versions in project files. ## General Integration Approach -OTS items are consumed via their public NuGet package API. No wrapper classes are -introduced unless the OTS API surface is too broad or its error model is -incompatible with ApiMark conventions. Error conditions from OTS items propagate -as exceptions to the consuming unit, which is responsible for logging and surfacing -them appropriately. +OTS items are consumed in two ways, depending on their delivery mechanism: + +- **NuGet packages** (Mono.Cecil, Antlr4.Runtime.Standard, Microsoft.Extensions.FileSystemGlobbing, + DemaConsulting.TestResults): consumed via their public NuGet package API. No wrapper classes are + introduced unless the OTS API surface is too broad or its error model is incompatible with ApiMark + conventions. +- **External CLI tools** (clang): invoked as an out-of-process command (`clang -ast-dump=json`). + The consuming unit (ClangAstParser) is responsible for launching the process, capturing its output, + and mapping any non-zero exit code or stderr output to an appropriate ApiMark diagnostic. + +Error conditions from OTS items propagate as exceptions (or diagnostic messages for CLI tools) to the +consuming unit, which is responsible for logging and surfacing them appropriately. ## Qualification Strategy diff --git a/docs/reqstream/api-mark-core.yaml b/docs/reqstream/api-mark-core.yaml index 9c82afa..687b7fb 100644 --- a/docs/reqstream/api-mark-core.yaml +++ b/docs/reqstream/api-mark-core.yaml @@ -40,7 +40,9 @@ sections: children: - ApiMarkCore-IMarkdownWriterFactory-DefineCreateMarkdownMethod - ApiMarkCore-IMarkdownWriterFactory-SupportRootLevelFiles - - ApiMarkCore-IMarkdownWriterFactory-RejectInvalidOutputDirectory + - ApiMarkCore-IMarkdownWriterFactory-RejectNullOutputDirectory + - ApiMarkCore-IMarkdownWriterFactory-RejectEmptyOutputDirectory + - ApiMarkCore-IMarkdownWriterFactory-RejectWhitespaceOutputDirectory - ApiMarkCore-IMarkdownWriterFactory-EnsureOutputDirectory tests: [ApiMarkCore_WriterFactory_CanCreate_RootAndSubfolderWriters] - id: ApiMarkCore-IMarkdownWriter-ProvideMarkdownOutputInterface @@ -58,6 +60,8 @@ sections: - ApiMarkCore-IMarkdownWriter-DefineWriteTable - ApiMarkCore-IMarkdownWriter-DefineWriteCodeBlock - ApiMarkCore-IMarkdownWriter-DefineWriteLink + - ApiMarkCore-IMarkdownWriter-WriteHeadingRejectInvalidLevel + - ApiMarkCore-IMarkdownWriter-ThrowObjectDisposedAfterDispose tests: [InMemoryMarkdownWriter_Write_AllOperations_RecordsInOrder] - id: ApiMarkCore-PathHelpers-ProvideSafePathCombinationHelper title: ApiMarkCore shall provide an internal helper for safely combining caller-supplied @@ -86,6 +90,9 @@ sections: - ApiMarkCore-IApiEmitter-ProduceApiMdEntrypoint - ApiMarkCore-IApiEmitter-SupportGradualDisclosure - ApiMarkCore-IApiEmitter-SupportSingleFile + tests: + - IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles + - IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd - id: ApiMarkCore-ProvideEmitConfiguration title: ApiMark Core shall provide an emit configuration contract. justification: | @@ -95,6 +102,12 @@ sections: children: - ApiMarkCore-EmitConfig-DefineFormat - ApiMarkCore-EmitConfig-DefineHeadingDepth + tests: + - EmitConfig_DefaultFormat_IsGradualDisclosure + - EmitConfig_DefaultHeadingDepth_IsOne + - EmitConfig_HeadingDepth_BelowMinimum_ThrowsArgumentOutOfRangeException + - EmitConfig_HeadingDepth_AboveMaximum_ThrowsArgumentOutOfRangeException + - EmitConfig_HeadingDepth_ValidNonDefault_SetsCorrectly - id: ApiMarkCore-GlobFileCollector-ProvideGlobFileCollector title: ApiMark Core shall provide a shared utility for discovering files from the filesystem using gitignore-style glob patterns. @@ -110,6 +123,8 @@ sections: - ApiMarkCore-GlobFileCollector-InferExtensionsFromBareStar - ApiMarkCore-GlobFileCollector-SupportExclusionPatterns - ApiMarkCore-GlobFileCollector-SkipNonExistentRoots + - ApiMarkCore-GlobFileCollector-SupportLiteralAbsolutePaths + - ApiMarkCore-GlobFileCollector-RejectNullArguments tests: - GlobFileCollector_Collect_EmptyPatterns_ReturnsEmptyList - GlobFileCollector_Collect_RelativeVhdPattern_FindsVhdFiles diff --git a/docs/reqstream/api-mark-core/emit-config.yaml b/docs/reqstream/api-mark-core/emit-config.yaml index 5f9682f..536bff3 100644 --- a/docs/reqstream/api-mark-core/emit-config.yaml +++ b/docs/reqstream/api-mark-core/emit-config.yaml @@ -15,8 +15,9 @@ sections: GradualDisclosure preserves backward compatibility with all existing callers that do not supply an explicit format. tests: - - IApiGenerator_Emit_GradualDisclosure_ProducesMultipleFiles - - IApiGenerator_Emit_SingleFile_ProducesSingleApiMd + - EmitConfig_DefaultFormat_IsGradualDisclosure + - IApiEmitter_Emit_GradualDisclosure_ProducesMultipleFiles + - IApiEmitter_Emit_SingleFile_ProducesSingleApiMd - id: ApiMarkCore-EmitConfig-DefineHeadingDepth title: EmitConfig shall make the top-level heading level selectable, defaulting to level 1. @@ -26,4 +27,19 @@ sections: controls the absolute heading level of the top-level section so that the emitter can shift all headings consistently. Defaulting to 1 produces H1 top-level headings, matching the expected output for standalone documents. - tests: [IApiGenerator_Emit_SingleFile_ProducesSingleApiMd] + tests: + - EmitConfig_DefaultHeadingDepth_IsOne + - IApiEmitter_Emit_SingleFile_ProducesSingleApiMd + - id: ApiMarkCore-EmitConfig-ValidateHeadingDepthRange + title: EmitConfig shall reject HeadingDepth values outside the range 1–3 + by throwing ArgumentOutOfRangeException at init time. + justification: | + The effective member heading level is HeadingDepth + 3. A depth of 3 + produces H6 member headings, which is the maximum in CommonMark. Values + above 3 would generate H7+ headings that are not rendered as headings by + standard processors. Enforcing the range at init time produces a clear + error at the point of misconfiguration. + tests: + - EmitConfig_HeadingDepth_BelowMinimum_ThrowsArgumentOutOfRangeException + - EmitConfig_HeadingDepth_AboveMaximum_ThrowsArgumentOutOfRangeException + - EmitConfig_HeadingDepth_ValidNonDefault_SetsCorrectly diff --git a/docs/reqstream/api-mark-core/file-markdown-writer-factory.yaml b/docs/reqstream/api-mark-core/file-markdown-writer-factory.yaml new file mode 100644 index 0000000..f8336ee --- /dev/null +++ b/docs/reqstream/api-mark-core/file-markdown-writer-factory.yaml @@ -0,0 +1,77 @@ +--- +# FileMarkdownWriterFactory unit requirements +sections: + - title: ApiMarkCore Requirements + sections: + - title: FileMarkdownWriterFactory Requirements + requirements: + - id: ApiMarkCore-FileMarkdownWriterFactory-RejectNullOutputDirectory + title: FileMarkdownWriterFactory shall throw ArgumentException when constructed + with a null output directory path. + justification: | + Failing fast at construction with a clear ArgumentException prevents + obscure I/O failures later when the first file is created, making + misconfiguration easier to diagnose than a deferred IOException. + tests: + - FileMarkdownWriterFactory_Constructor_NullDirectory_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriterFactory-RejectEmptyOutputDirectory + title: FileMarkdownWriterFactory shall throw ArgumentException when constructed + with an empty string output directory path. + justification: | + An empty string cannot produce a valid directory path; failing fast at + construction prevents obscure I/O failures later. + tests: + - FileMarkdownWriterFactory_Constructor_EmptyDirectory_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriterFactory-RejectWhitespaceOutputDirectory + title: FileMarkdownWriterFactory shall throw ArgumentException when constructed + with a whitespace-only output directory path. + justification: | + A whitespace-only string cannot produce a valid directory path; failing fast + at construction makes misconfiguration immediately diagnosable. + tests: + - FileMarkdownWriterFactory_Constructor_WhitespaceDirectory_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriterFactory-RejectNullName + title: FileMarkdownWriterFactory shall throw ArgumentException when + CreateMarkdown is called with a null file name. + justification: | + A null name cannot produce a valid file path; failing at the call site + produces a clear error immediately attributable to the misuse rather than + an obscure downstream IOException. + tests: + - FileMarkdownWriterFactory_CreateMarkdown_NullName_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriterFactory-RejectEmptyName + title: FileMarkdownWriterFactory shall throw ArgumentException when + CreateMarkdown is called with an empty string file name. + justification: | + An empty file name cannot produce a valid file path; failing at the call + site produces a clear error immediately attributable to the misuse. + tests: + - FileMarkdownWriterFactory_CreateMarkdown_EmptyName_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriterFactory-RejectWhitespaceName + title: FileMarkdownWriterFactory shall throw ArgumentException when + CreateMarkdown is called with a whitespace-only file name. + justification: | + A whitespace-only name cannot produce a valid file path; failing immediately + prevents confusing downstream I/O errors. + tests: + - FileMarkdownWriterFactory_CreateMarkdown_WhitespaceName_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriterFactory-SupportRootLevelFiles + title: FileMarkdownWriterFactory shall create a file directly under the + output root when CreateMarkdown is called with an empty or whitespace subFolder. + justification: | + The api.md entrypoint file is always at the root of the output tree. + Accepting an empty or whitespace subFolder without error allows generators to create + that fixed entrypoint without special-casing. + tests: + - FileMarkdownWriterFactory_CreateMarkdown_RootLevel_CreatesFile + - id: ApiMarkCore-FileMarkdownWriterFactory-CreateOutputDirectoryOnUse + title: FileMarkdownWriterFactory shall create the output directory tree + (including the subFolder) if it does not exist before returning the + writer, so that callers do not need to pre-create directories. + justification: | + Language generators work with logical paths and must not be responsible + for managing the physical directory tree; delegating directory creation + to the factory keeps generators free of filesystem-management concerns. + tests: + - FileMarkdownWriterFactory_CreateMarkdown_NonExistentDirectory_CreatesDirectory + - FileMarkdownWriterFactory_CreateMarkdown_WithSubFolder_CreatesDirectoryAndFile diff --git a/docs/reqstream/api-mark-core/file-markdown-writer.yaml b/docs/reqstream/api-mark-core/file-markdown-writer.yaml new file mode 100644 index 0000000..d0e6364 --- /dev/null +++ b/docs/reqstream/api-mark-core/file-markdown-writer.yaml @@ -0,0 +1,230 @@ +--- +# FileMarkdownWriter unit requirements +sections: + - title: ApiMarkCore Requirements + sections: + - title: FileMarkdownWriter Requirements + requirements: + - id: ApiMarkCore-FileMarkdownWriter-WriteHeadingAsAtxSyntax + title: FileMarkdownWriter shall write ATX-style headings using the + correct number of # characters for the requested heading level. + justification: | + Generated Markdown files use headings to provide navigable structure. + ATX-style headings are universally supported and produce correct + outline levels in all standard Markdown renderers. + tests: + - FileMarkdownWriter_WriteHeading_Level1_WritesCorrectMarkdown + - FileMarkdownWriter_WriteHeading_Level3_WritesCorrectMarkdown + - id: ApiMarkCore-FileMarkdownWriter-WriteSignatureAsCodeFence + title: FileMarkdownWriter shall write API signatures as fenced code blocks + with the specified language identifier. + justification: | + API signatures require distinct code-block rendering so they are + visually separated from prose and recognized by syntax highlighters + in downstream tools and AI consumers. + tests: + - FileMarkdownWriter_WriteSignature_ValidArgs_WritesCodeFence + - id: ApiMarkCore-FileMarkdownWriter-WriteParagraphAsText + title: FileMarkdownWriter shall write prose paragraphs as plain text + followed by a blank line. + justification: | + Summaries, remarks, and return descriptions are prose paragraphs. + A trailing blank line correctly closes the Markdown paragraph block + so that subsequent elements are rendered separately. + tests: + - FileMarkdownWriter_WriteParagraph_ValidText_WritesParagraphText + - id: ApiMarkCore-FileMarkdownWriter-WriteTableAsPipeTable + title: FileMarkdownWriter shall write tables as pipe-delimited GFM tables + with a header row, a separator row of dashes, and data rows. Pipe + characters in cell values shall be escaped as \|. + justification: | + Namespace, type, and member summaries use tables for compact scanning. + Pipe escaping prevents structural breakage when API names or descriptions + contain literal pipe characters. + tests: + - FileMarkdownWriter_WriteTable_ValidArgs_WritesPipeTable + - FileMarkdownWriter_WriteTable_CellWithPipe_EscapesPipe + - id: ApiMarkCore-FileMarkdownWriter-WriteCodeBlockAsCodeFence + title: FileMarkdownWriter shall write code examples as fenced code blocks + with the specified language identifier. + justification: | + Usage examples and multi-line code snippets require fenced code block + rendering to remain readable and syntax-aware in Markdown viewers. + tests: + - FileMarkdownWriter_WriteCodeBlock_ValidArgs_WritesCodeFence + - id: ApiMarkCore-FileMarkdownWriter-WriteLinkAsMarkdownLink + title: FileMarkdownWriter shall write navigation links as standard inline + Markdown links with the supplied text and relative path. + justification: | + Gradual disclosure depends on explicit links between generated files. + Inline Markdown link syntax is universally supported and produces + clickable navigation in all standard renderers. + tests: + - FileMarkdownWriter_WriteLink_ValidArgs_WritesMarkdownLink + - id: ApiMarkCore-FileMarkdownWriter-FlushAndCloseOnDispose + title: FileMarkdownWriter shall flush all pending content and release the + underlying file handle when Dispose is called. + justification: | + Generators create one writer per output file and dispose it when done. + Flushing on disposal ensures content is written to disk before callers + proceed, and releasing the handle prevents resource leaks. + tests: + - FileMarkdownWriter_Dispose_AfterWrite_FlushesAndClosesFile + - id: ApiMarkCore-FileMarkdownWriter-WriteHeadingRejectInvalidLevel + title: FileMarkdownWriter shall throw ArgumentOutOfRangeException when + WriteHeading is called with a level less than 1 or greater than 6. + justification: | + CommonMark defines exactly six ATX heading levels; values outside 1–6 are + not representable as standard headings and indicate a generator defect. + Rejecting invalid levels immediately provides a clear diagnostic. + tests: + - FileMarkdownWriter_WriteHeading_ZeroLevel_ThrowsArgumentOutOfRangeException + - FileMarkdownWriter_WriteHeading_SevenLevel_ThrowsArgumentOutOfRangeException + - id: ApiMarkCore-FileMarkdownWriter-ThrowObjectDisposedAfterDispose + title: FileMarkdownWriter shall throw ObjectDisposedException when any write + method is called after the writer has been disposed. + justification: | + Generators rely on deterministic resource cleanup; accepting write calls + on a disposed writer would silently discard content. Throwing immediately + surfaces the programming error so it can be corrected. + tests: + - FileMarkdownWriter_WriteHeading_AfterDispose_ThrowsObjectDisposedException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullSignatureLanguage + title: FileMarkdownWriter shall throw ArgumentNullException when WriteSignature + is called with a null language parameter. + justification: | + A null language identifier cannot produce a valid fenced code block; rejecting + it immediately at the call site prevents obscure downstream rendering failures. + tests: + - FileMarkdownWriter_WriteSignature_NullLanguage_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullSignatureCode + title: FileMarkdownWriter shall throw ArgumentNullException when WriteSignature + is called with a null code parameter. + justification: | + A null code body cannot produce valid Markdown output; rejecting it at the + call site surfaces the defect immediately with a clear diagnostic. + tests: + - FileMarkdownWriter_WriteSignature_NullCode_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullParagraphText + title: FileMarkdownWriter shall throw ArgumentNullException when WriteParagraph + is called with a null text parameter. + justification: | + A null text argument cannot produce valid paragraph output; immediate rejection + prevents silent generation of empty or malformed documentation blocks. + tests: + - FileMarkdownWriter_WriteParagraph_NullText_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullTableHeaders + title: FileMarkdownWriter shall throw ArgumentNullException when WriteTable + is called with a null headers parameter. + justification: | + A null headers array cannot produce a valid pipe-delimited table; rejecting + it at the call site prevents a NullReferenceException buried in rendering logic. + tests: + - FileMarkdownWriter_WriteTable_NullHeaders_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullTableRows + title: FileMarkdownWriter shall throw ArgumentNullException when WriteTable + is called with a null rows parameter. + justification: | + A null rows sequence cannot be enumerated to produce table data rows; + rejecting it immediately at the call site surfaces the defect clearly. + tests: + - FileMarkdownWriter_WriteTable_NullRows_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullCodeBlockLanguage + title: FileMarkdownWriter shall throw ArgumentNullException when WriteCodeBlock + is called with a null language parameter. + justification: | + A null language identifier cannot produce a valid fenced code block; rejecting + it immediately prevents obscure downstream rendering failures. + tests: + - FileMarkdownWriter_WriteCodeBlock_NullLanguage_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullCodeBlockCode + title: FileMarkdownWriter shall throw ArgumentNullException when WriteCodeBlock + is called with a null code parameter. + justification: | + A null code body cannot produce valid Markdown output; immediate rejection + surfaces the defect clearly. + tests: + - FileMarkdownWriter_WriteCodeBlock_NullCode_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullLinkText + title: FileMarkdownWriter shall throw ArgumentNullException when WriteLink + is called with a null text parameter. + justification: | + A null display label cannot produce a valid inline Markdown link; rejecting + it at the call site surfaces the programming error immediately. + tests: + - FileMarkdownWriter_WriteLink_NullText_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullLinkRelativePath + title: FileMarkdownWriter shall throw ArgumentNullException when WriteLink + is called with a null relativePath parameter. + justification: | + A null path cannot produce a valid link href; rejecting it immediately + prevents silent generation of broken navigation links. + tests: + - FileMarkdownWriter_WriteLink_NullRelativePath_ThrowsArgumentNullException + - id: ApiMarkCore-FileMarkdownWriter-RejectEmptyHeaders + title: FileMarkdownWriter shall throw ArgumentException when WriteTable is + called with an empty headers array. + justification: | + A table with no header columns is not valid GFM syntax and cannot be + rendered as a table by any standard Markdown processor. Rejecting empty + headers at the call site prevents silent generation of malformed output. + tests: + - FileMarkdownWriter_WriteTable_EmptyHeaders_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriter-RejectMismatchedRowLength + title: FileMarkdownWriter shall throw ArgumentException when WriteTable is + called with a row that has a different number of cells than the headers array. + justification: | + A row whose column count differs from the header count produces a structurally + malformed pipe table that will not render correctly in any standard Markdown + processor. Rejecting mismatched rows before writing any output prevents + partial table corruption. + tests: + - FileMarkdownWriter_WriteTable_MismatchedRowLength_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriter-RejectEmptyHeadingText + title: FileMarkdownWriter shall throw ArgumentException when WriteHeading is + called with an empty text parameter. + justification: | + A heading with no text produces an ATX prefix with no label (e.g. "# "), + which is not meaningful Markdown and indicates a generator defect. Rejecting + empty text immediately provides a clear diagnostic. + tests: + - FileMarkdownWriter_WriteHeading_EmptyText_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriter-RejectEmptyLinkText + title: FileMarkdownWriter shall throw ArgumentException when WriteLink is + called with an empty text parameter. + justification: | + A link with no visible label (e.g. "[](path)") is not navigable and + indicates a generator defect. Rejecting empty text immediately provides a + clear diagnostic and prevents silent generation of unusable navigation links. + tests: + - FileMarkdownWriter_WriteLink_EmptyText_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullRowElements + title: FileMarkdownWriter shall throw ArgumentException when WriteTable is + called with a rows sequence that contains a null element. + justification: | + A null row reference within the rows enumerable cannot be formatted as table + cells and would cause a NullReferenceException in rendering logic. Rejecting + null row elements before writing any output surfaces the defect clearly and + prevents partial table output. + tests: + - FileMarkdownWriter_WriteTable_NullRowElement_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullHeaderElements + title: FileMarkdownWriter shall throw ArgumentException when WriteTable is + called with a headers array that contains a null element. + justification: | + A null header element cannot produce a valid column label in a pipe-delimited + table and would cause a NullReferenceException in rendering logic. Rejecting + null header elements before writing any output provides a clear diagnostic + and prevents partial or malformed table output. + tests: + - FileMarkdownWriter_WriteTable_NullHeaderElement_ThrowsArgumentException + - id: ApiMarkCore-FileMarkdownWriter-RejectNullCellElements + title: FileMarkdownWriter shall throw ArgumentException when WriteTable is + called with a row that contains a null cell element. + justification: | + A null cell within a row cannot be rendered as Markdown table content and + would cause a NullReferenceException in rendering logic. Rejecting null cell + elements before writing any output provides a clear diagnostic and prevents + partial table output. + tests: + - FileMarkdownWriter_WriteTable_NullCellElement_ThrowsArgumentException diff --git a/docs/reqstream/api-mark-core/glob-file-collector.yaml b/docs/reqstream/api-mark-core/glob-file-collector.yaml index 384e021..2ec04dd 100644 --- a/docs/reqstream/api-mark-core/glob-file-collector.yaml +++ b/docs/reqstream/api-mark-core/glob-file-collector.yaml @@ -53,3 +53,26 @@ sections: spurious failures and allows partial results from other patterns. tests: - GlobFileCollector_Collect_NonExistentRoot_ReturnsEmptyWithoutThrowing + - id: ApiMarkCore-GlobFileCollector-SupportLiteralAbsolutePaths + title: GlobFileCollector shall treat an absolute pattern with no glob + metacharacters as a literal file path and add or remove it directly + without directory traversal, when the file exists and its extension + appears in languageExtensions. + justification: | + Callers need to include or exclude individual files by exact path without + glob syntax. Literal-path support covers this use case and avoids the + overhead of directory traversal when only a single file is targeted. + tests: + - GlobFileCollector_Collect_LiteralAbsoluteFilePath_SelectsExactFile + - GlobFileCollector_Collect_LiteralAbsoluteExclusionPath_RemovesExactFile + - id: ApiMarkCore-GlobFileCollector-RejectNullArguments + title: GlobFileCollector.Collect shall throw ArgumentNullException when + patterns, languageExtensions, or workingDirectory is null. + justification: | + Null arguments indicate a programming error in the caller. Rejecting them + immediately with a clear diagnostic prevents a deferred NullReferenceException + buried in pattern evaluation logic that would be harder to attribute. + tests: + - GlobFileCollector_Collect_NullPatterns_ThrowsArgumentNullException + - GlobFileCollector_Collect_NullLanguageExtensions_ThrowsArgumentNullException + - GlobFileCollector_Collect_NullWorkingDirectory_ThrowsArgumentNullException diff --git a/docs/reqstream/api-mark-core/i-api-emitter.yaml b/docs/reqstream/api-mark-core/i-api-emitter.yaml index 613e856..1651e7c 100644 --- a/docs/reqstream/api-mark-core/i-api-emitter.yaml +++ b/docs/reqstream/api-mark-core/i-api-emitter.yaml @@ -17,6 +17,8 @@ sections: decouples the emitter from Console so output can be captured, suppressed, or redirected by different hosts. tests: + - IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles + - IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd - IApiGenerator_Parse_WithMinimalStub_ExecutesSuccessfully - IApiGenerator_Emit_OutputDirectory_ContainsApiMd - id: ApiMarkCore-IApiEmitter-SupportGradualDisclosure @@ -27,7 +29,9 @@ sections: format optimized for AI agent navigation. An emitter must honor this format when selected so callers can produce the expected multi-file layout without knowing which language module generated the symbol data. - tests: [IApiGenerator_Emit_GradualDisclosure_ProducesMultipleFiles] + tests: + - IApiEmitter_Emit_GradualDisclosure_ProducesMultipleFiles + - IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles - id: ApiMarkCore-IApiEmitter-SupportSingleFile title: IApiEmitter.Emit shall support writing all content into a single api.md file when EmitConfig.Format is SingleFile. @@ -36,7 +40,9 @@ sections: larger document. An emitter must honor this format when selected so callers can produce a single consolidated Markdown file without knowing which language module generated the symbol data. - tests: [IApiGenerator_Emit_SingleFile_ProducesSingleApiMd] + tests: + - IApiEmitter_Emit_SingleFile_ProducesSingleApiMd + - IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd - id: ApiMarkCore-IApiEmitter-ProduceApiMdEntrypoint title: IApiEmitter.Emit shall produce a file named "api.md" as the fixed top-level entrypoint of every generated output tree. @@ -48,3 +54,25 @@ sections: - IApiGenerator_Emit_OutputDirectory_ContainsApiMd - IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles - IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd + - id: ApiMarkCore-IApiEmitter-RejectNullFactory + title: IApiEmitter.Emit shall reject a null factory argument. + justification: | + A null factory would cause a NullReferenceException inside the emitter + on the first CreateMarkdown call, with no actionable message. Failing fast + with ArgumentNullException at the start of Emit makes the error immediately + diagnosable. + tests: [IApiEmitter_Emit_NullFactory_ThrowsArgumentNullException] + - id: ApiMarkCore-IApiEmitter-RejectNullConfig + title: IApiEmitter.Emit shall reject a null config argument. + justification: | + A null EmitConfig would cause a NullReferenceException when the emitter + reads Format or HeadingDepth. Failing fast with ArgumentNullException at + the entry point makes misconfiguration immediately diagnosable. + tests: [IApiEmitter_Emit_NullConfig_ThrowsArgumentNullException] + - id: ApiMarkCore-IApiEmitter-RejectNullContext + title: IApiEmitter.Emit shall reject a null context argument. + justification: | + A null IContext would cause a NullReferenceException when the emitter + emits a diagnostic message. Failing fast with ArgumentNullException at + the entry point makes misconfiguration immediately diagnosable. + tests: [IApiEmitter_Emit_NullContext_ThrowsArgumentNullException] diff --git a/docs/reqstream/api-mark-core/i-api-generator.yaml b/docs/reqstream/api-mark-core/i-api-generator.yaml index 495ecd5..bde2b53 100644 --- a/docs/reqstream/api-mark-core/i-api-generator.yaml +++ b/docs/reqstream/api-mark-core/i-api-generator.yaml @@ -5,18 +5,25 @@ sections: sections: - title: IApiGenerator Requirements requirements: - - id: ApiMarkCore-IApiGenerator-DefineParseMethod - title: IApiGenerator shall accept a caller-supplied diagnostic channel - and return an emitter ready to write the parsed component's documentation. + - id: ApiMarkCore-IApiGenerator-AcceptContextParameter + title: IApiGenerator.Parse shall accept an IContext parameter as the + caller-supplied diagnostic channel for informational and error output. justification: | - The caller needs one consistent invocation point for every language - generator so symbol parsing can be triggered without knowing the - implementation details of each language-specific module. Returning an - IApiEmitter separates parsing from output-format selection, allowing the - caller to choose the output format at emit time rather than at parse time. IContext decouples generators from Console so output can be captured, - suppressed, or redirected by the caller. + suppressed, or redirected by the caller without changing the generator + implementation. The caller supplies an IContext reference that the + generator uses for all diagnostic output during parsing. tests: [IApiGenerator_Parse_WithMinimalStub_ExecutesSuccessfully] + - id: ApiMarkCore-IApiGenerator-ReturnIApiEmitter + title: IApiGenerator.Parse shall return an IApiEmitter that holds all + data required to emit the parsed component's documentation. + justification: | + Returning an IApiEmitter separates the I/O-heavy parsing phase from + output-format selection, allowing the caller to choose the output format + at emit time rather than at parse time. The returned emitter is + format-agnostic; format selection is the responsibility of the caller + when invoking IApiEmitter.Emit. + tests: [ApiMarkCore_GeneratorContract_SupportedLanguage_CanBeInvoked] - id: ApiMarkCore-IApiGenerator-AllowConstructionTimeConfiguration title: IApiGenerator shall allow language-specific configuration to be supplied when an implementation is constructed before Parse is called. @@ -25,3 +32,11 @@ sections: file paths or visibility settings, so configuration must be captured before parsing starts while keeping the runtime invocation uniform. tests: [IApiGenerator_Implementation_UsesConstructionConfiguration] + - id: ApiMarkCore-IApiGenerator-RejectNullContext + title: IApiGenerator.Parse shall reject a null context argument. + justification: | + Passing a null IContext to Parse would cause a NullReferenceException + deep inside the generator implementation with no actionable message. + Failing fast with ArgumentNullException at the entry point makes + misconfiguration immediately diagnosable by the caller. + tests: [IApiGenerator_Parse_NullContext_ThrowsArgumentNullException] diff --git a/docs/reqstream/api-mark-core/i-context.yaml b/docs/reqstream/api-mark-core/i-context.yaml index 7538466..8de8680 100644 --- a/docs/reqstream/api-mark-core/i-context.yaml +++ b/docs/reqstream/api-mark-core/i-context.yaml @@ -22,3 +22,23 @@ sections: channel (for example stderr or a build-system error log) without parsing message content. tests: [IContext_WriteError_CapturesMessage_InErrors] + - id: ApiMarkCore-IContext-RejectNullWriteLine + title: IContext implementations shall throw ArgumentNullException when + WriteLine is called with a null message. + justification: | + Null messages would corrupt the captured message list and mislead + downstream assertions in test doubles. Enforcing the null contract + at the implementation boundary ensures callers receive a clear error + immediately attributable to the misuse. + tests: + - InMemoryContext_WriteLine_NullMessage_ThrowsArgumentNullException + - id: ApiMarkCore-IContext-RejectNullWriteError + title: IContext implementations shall throw ArgumentNullException when + WriteError is called with a null message. + justification: | + Null messages would corrupt the captured message list and mislead + downstream assertions in test doubles. Enforcing the null contract + at the implementation boundary ensures callers receive a clear error + immediately attributable to the misuse. + tests: + - InMemoryContext_WriteError_NullMessage_ThrowsArgumentNullException diff --git a/docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml b/docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml index 564436b..97f7250 100644 --- a/docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml +++ b/docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml @@ -13,23 +13,50 @@ sections: the storage mechanism so that the same generator code can write to disk in production and to memory in tests. tests: [IMarkdownWriterFactory_HasCreateMarkdown_Method] - - id: ApiMarkCore-IMarkdownWriterFactory-SupportRootLevelFiles - title: IMarkdownWriterFactory shall support creating root-level files - when subFolder is an empty string. + - id: ApiMarkCore-IMarkdownWriterFactory-AcceptEmptySubFolderAsRoot + title: IMarkdownWriterFactory shall create a root-level file when subFolder + is an empty string. justification: | The api.md entrypoint file is always at the root of the output tree, so - the factory must accept an empty subFolder without error to allow + the factory must accept an empty string subFolder without error to allow generators to create that fixed entrypoint. - tests: [IMarkdownWriterFactory_CreateMarkdown_EmptySubFolder_IsRootLevel] - - id: ApiMarkCore-IMarkdownWriterFactory-RejectInvalidOutputDirectory - title: IMarkdownWriterFactory shall reject a null, empty, or whitespace - output directory path at construction time. + tests: + - IMarkdownWriterFactory_CreateMarkdown_EmptySubFolder_IsRootLevel + - FileMarkdownWriterFactory_CreateMarkdown_RootLevel_CreatesFile + - id: ApiMarkCore-IMarkdownWriterFactory-AcceptWhitespaceSubFolderAsRoot + title: IMarkdownWriterFactory shall create a root-level file when subFolder + is a whitespace-only string. + justification: | + Treating a whitespace-only subFolder identically to an empty string prevents + generators from having to trim input before every CreateMarkdown call and + makes the contract more robust against accidental trailing whitespace in + configuration values. + tests: + - FileMarkdownWriterFactory_CreateMarkdown_WhitespaceSubFolder_CreatesRootLevelFile + - id: ApiMarkCore-IMarkdownWriterFactory-RejectNullOutputDirectory + title: IMarkdownWriterFactory shall reject a null output directory path + at construction time. justification: | Failing fast at construction with a clear ArgumentException prevents obscure I/O failures later when the first file is created, making misconfiguration easier to diagnose. tests: - FileMarkdownWriterFactory_Constructor_NullDirectory_ThrowsArgumentException + - id: ApiMarkCore-IMarkdownWriterFactory-RejectEmptyOutputDirectory + title: IMarkdownWriterFactory shall reject an empty string output directory + path at construction time. + justification: | + An empty string cannot produce a valid directory path; failing fast at + construction with a clear ArgumentException prevents obscure I/O failures. + tests: + - FileMarkdownWriterFactory_Constructor_EmptyDirectory_ThrowsArgumentException + - id: ApiMarkCore-IMarkdownWriterFactory-RejectWhitespaceOutputDirectory + title: IMarkdownWriterFactory shall reject a whitespace-only output directory + path at construction time. + justification: | + A whitespace-only string cannot produce a valid directory path; failing fast + at construction prevents obscure I/O failures later. + tests: - FileMarkdownWriterFactory_Constructor_WhitespaceDirectory_ThrowsArgumentException - id: ApiMarkCore-IMarkdownWriterFactory-EnsureOutputDirectory title: IMarkdownWriterFactory shall create the output directory and any diff --git a/docs/reqstream/api-mark-core/i-markdown-writer.yaml b/docs/reqstream/api-mark-core/i-markdown-writer.yaml index e22eafc..f119810 100644 --- a/docs/reqstream/api-mark-core/i-markdown-writer.yaml +++ b/docs/reqstream/api-mark-core/i-markdown-writer.yaml @@ -41,6 +41,25 @@ sections: Namespace, type, and member summaries rely on tables to present compact API information that can be scanned quickly by humans and AI tools. tests: [IMarkdownWriter_WriteTable_ValidArgs_DoesNotThrow] + - id: ApiMarkCore-IMarkdownWriter-WriteTableRejectEmptyHeaders + title: IMarkdownWriter shall reject an empty headers array with + ArgumentException when WriteTable is called. + justification: | + A table with no header columns is not valid GFM syntax; implementations must + enforce this constraint consistently so callers receive a clear diagnostic + instead of producing malformed output that fails silently in Markdown renderers. + tests: + - FileMarkdownWriter_WriteTable_EmptyHeaders_ThrowsArgumentException + - id: ApiMarkCore-IMarkdownWriter-WriteTableRejectMismatchedRowLength + title: IMarkdownWriter shall reject a row whose column count differs from the + headers array length with ArgumentException when WriteTable is called. + justification: | + A row with a different number of cells than the header produces a structurally + invalid pipe table. Implementations must enforce this constraint so callers + discover structural defects immediately rather than generating malformed + documentation. + tests: + - FileMarkdownWriter_WriteTable_MismatchedRowLength_ThrowsArgumentException - id: ApiMarkCore-IMarkdownWriter-DefineWriteCodeBlock title: IMarkdownWriter shall define a WriteCodeBlock operation that accepts a language identifier and code text. @@ -55,3 +74,73 @@ sections: Gradual disclosure depends on explicit links between generated files, so the writer contract must support relative navigation links directly. tests: [IMarkdownWriter_WriteLink_ValidArgs_DoesNotThrow] + - id: ApiMarkCore-IMarkdownWriter-WriteHeadingRejectInvalidLevel + title: IMarkdownWriter shall reject heading levels outside the range 1–6, + throwing ArgumentOutOfRangeException for any out-of-range value. + justification: | + CommonMark defines only six ATX heading levels; values outside this range + cannot produce valid heading markup and indicate a generator defect that + should surface immediately with a clear diagnostic rather than silently + producing malformed output. + tests: + - FileMarkdownWriter_WriteHeading_ZeroLevel_ThrowsArgumentOutOfRangeException + - FileMarkdownWriter_WriteHeading_SevenLevel_ThrowsArgumentOutOfRangeException + - id: ApiMarkCore-IMarkdownWriter-ThrowObjectDisposedAfterDispose + title: IMarkdownWriter shall throw ObjectDisposedException when any write + method is called after the writer has been disposed. + justification: | + Generators rely on deterministic resource cleanup via IDisposable; calling + write methods on a disposed writer indicates a programming error that must + be surfaced immediately to prevent silent data loss or corruption. + tests: + - FileMarkdownWriter_WriteHeading_AfterDispose_ThrowsObjectDisposedException + - id: ApiMarkCore-IMarkdownWriter-WriteHeadingRejectEmptyText + title: IMarkdownWriter shall reject an empty heading text string with + ArgumentException when WriteHeading is called. + justification: | + A heading with no text produces an ATX prefix with no label (e.g. "# "), + which is not meaningful Markdown. Implementations must enforce this + constraint so callers receive a clear diagnostic instead of silently + generating malformed output. + tests: + - FileMarkdownWriter_WriteHeading_EmptyText_ThrowsArgumentException + - id: ApiMarkCore-IMarkdownWriter-WriteLinkRejectEmptyText + title: IMarkdownWriter shall reject an empty link text string with + ArgumentException when WriteLink is called. + justification: | + A link with no visible label (e.g. "[](path)") is not navigable and + indicates a generator defect. Implementations must enforce this constraint + so callers receive a clear diagnostic rather than silently generating + unusable navigation links. + tests: + - FileMarkdownWriter_WriteLink_EmptyText_ThrowsArgumentException + - id: ApiMarkCore-IMarkdownWriter-WriteTableRejectNullRowElements + title: IMarkdownWriter shall reject a rows sequence containing a null element + with ArgumentException when WriteTable is called. + justification: | + A null row reference within the rows enumerable cannot be rendered as table + cells. Implementations must detect and reject null row elements before + writing any output so callers receive a clear diagnostic instead of a + NullReferenceException buried in rendering logic. + tests: + - FileMarkdownWriter_WriteTable_NullRowElement_ThrowsArgumentException + - id: ApiMarkCore-IMarkdownWriter-WriteTableRejectNullHeaderElements + title: IMarkdownWriter shall reject a headers array containing a null element + with ArgumentException when WriteTable is called. + justification: | + A null element in the headers array cannot produce a valid column label in a + pipe-delimited table. Implementations must detect and reject null header + elements so callers receive a clear diagnostic instead of a + NullReferenceException in rendering logic. + tests: + - FileMarkdownWriter_WriteTable_NullHeaderElement_ThrowsArgumentException + - id: ApiMarkCore-IMarkdownWriter-WriteTableRejectNullCellElements + title: IMarkdownWriter shall reject a row containing a null cell element + with ArgumentException when WriteTable is called. + justification: | + A null cell within a row cannot be rendered as Markdown table content. + Implementations must detect and reject null cell elements before writing any + output so callers receive a clear diagnostic instead of a NullReferenceException + in rendering logic. + tests: + - FileMarkdownWriter_WriteTable_NullCellElement_ThrowsArgumentException diff --git a/docs/reqstream/api-mark-core/path-helpers.yaml b/docs/reqstream/api-mark-core/path-helpers.yaml index 6661842..041d99e 100644 --- a/docs/reqstream/api-mark-core/path-helpers.yaml +++ b/docs/reqstream/api-mark-core/path-helpers.yaml @@ -6,14 +6,16 @@ sections: - title: PathHelpers Requirements requirements: - id: ApiMarkCore-PathHelpers-CombineValidRelativeSegments - title: PathHelpers shall combine a trusted base path with one or more + title: PathHelpers shall combine a trusted base path with zero or more caller-supplied path segments in order, provided the result resolves - within the base. + within the base. When no segments are supplied, the method returns + basePath unchanged. justification: | ApiMark callers need one reusable helper that can build derived file-system paths without duplicating path-joining logic at every call site. Segments that contain ".." or start with a separator are accepted as long as the - final combined path does not escape the base directory. + final combined path does not escape the base directory. Accepting zero + segments allows callers to pass the base path directly without special-casing. tests: - PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly - PathHelpers_SafePathCombine_CurrentDirectoryReference_CombinesCorrectly @@ -23,6 +25,7 @@ sections: - PathHelpers_SafePathCombine_BacktrackWithinBase_CombinesCorrectly - PathHelpers_SafePathCombine_FilenameWithDoubleDots_CombinesCorrectly - PathHelpers_SafePathCombine_AbsoluteSegment_WithinBase_CombinesCorrectly + - PathHelpers_SafePathCombine_NoSegments_ReturnsBasePath - id: ApiMarkCore-PathHelpers-RejectTraversalSegments title: PathHelpers shall reject any caller-supplied path combination that resolves outside the base directory. diff --git a/docs/user_guide/cpp.md b/docs/user_guide/cpp.md index f9c1428..6be5940 100644 --- a/docs/user_guide/cpp.md +++ b/docs/user_guide/cpp.md @@ -140,6 +140,41 @@ Block form: Missing `@brief` tags render as *No description provided.* in the output. +## Example Code Blocks + +ApiMark captures Doxygen `@code`/`@endcode` blocks from doc-comments and renders them as +fenced code blocks in the generated Markdown output. Place a `@code`/`@endcode` block +inside any doc-comment to illustrate usage of the documented declaration. + +Single-line form: + +```cpp +/// @brief Adds two integers. +/// @code +/// int result = Add(2, 3); // result is 5 +/// @endcode +int Add(int a, int b); +``` + +Block form: + +```cpp +/** + * @brief Parses a configuration file. + * @param path Path to the configuration file. + * @return Parsed configuration object. + * @code + * auto cfg = ParseConfig("app.cfg"); + * std::cout << cfg.value("timeout"); + * @endcode + */ +Config ParseConfig(const std::string& path); +``` + +The content between `@code` and `@endcode` is extracted verbatim by `ClangAstParser` +from the Doxygen `BlockCommandComment` AST node and emitted as a fenced `cpp` code block +in the output. The code block appears on the member detail page under the *Example* heading. + ## Output Structure ApiMark supports two output formats selectable via `--format`. diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index c24d492..0142399 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -14,9 +14,12 @@ comments, header files, docstrings, etc.). Covers the `apimark` CLI tool and the `DemaConsulting.ApiMark.MSBuild` NuGet package integration. Excludes internal architecture and design details. This guide includes installation instructions, a global CLI reference, MSBuild integration concepts, and -dedicated language sections for .NET, C++, and VHDL — each covering CLI options, file -discovery, documented constructs, doc comment format, output structure, and -language-specific MSBuild configuration. +dedicated language sections for .NET, C++, and VHDL — each covering CLI options, +documented constructs, doc comment format, and output structure. The C++ and VHDL +sections also cover file discovery (those generators accept source-file paths or glob +patterns); the .NET section uses compiled assembly input instead and does not have a +file-discovery step. The .NET and C++ sections also cover language-specific MSBuild +configuration; VHDL has no MSBuild integration. ## References diff --git a/docs/verification/api-mark-core.md b/docs/verification/api-mark-core.md index dcd8fdd..9a9a4c2 100644 --- a/docs/verification/api-mark-core.md +++ b/docs/verification/api-mark-core.md @@ -8,12 +8,12 @@ test doubles from `test/ApiMark.Core.TestHelpers/` to confirm that callers can i the full range of structured operations through the `IApiGenerator` and `IMarkdownWriter` interfaces in a predictable sequence without depending on a specific renderer or generator implementation. Additional PathHelpers tests verify safe -combination of valid relative paths and rejection of traversal, rooted, and null input. +combination of valid relative paths and rejection of traversal attempts and null input. No MSBuild or external process involvement is required. ## Test Environment -N/A — standard test environment using the .NET test runner is sufficient for ApiMark.Core +N/A - standard test environment using the .NET test runner is sufficient for ApiMark.Core unit tests. ## Acceptance Criteria @@ -26,8 +26,9 @@ unit tests. - `IMarkdownWriterFactory` can create writers for root and subfolder paths. - `IMarkdownWriter` structured operations (headings, paragraphs, code blocks, tables, links) are forwarded with the correct values and sequence. -- `PathHelpers` combines valid relative paths and rejects traversal, rooted, and null - path input. +- `PathHelpers` combines valid relative paths and rejects traversal and null + path input. Rooted (absolute) path segments are not rejected — they are + concatenated as relative components within the base directory. - `GlobFileCollector` collects files matching glob patterns: empty patterns return empty; bare-star segments apply language-extension filtering; absolute patterns work independently of working directory; exclusions remove files; non-existent roots @@ -40,7 +41,7 @@ unit tests. `Lines`, error messages reach `Errors`, and no cross-contamination occurs between channels. This scenario is tested by `IContext_WriteLine_CapturesMessage_InLines`, `IContext_WriteError_CapturesMessage_InErrors`, and -`ApiMarkCore_ContextContract_WrittenMessages_AreAccessibleForAssertion`. +`InMemoryContext_WriteLineAndWriteError_RouteToSeparateChannels`. **Generator contract is satisfied**: Verifies that a language-generator implementation compiles against the `IApiGenerator` interface and can be invoked through an interface @@ -58,11 +59,12 @@ by generators. This scenario is tested by operations (headings, paragraphs, code blocks, tables, links) are forwarded through the `IMarkdownWriter` contract with the correct values and without loss of structure. This scenario is tested by -`ApiMarkCore_MarkdownWriterContract_FileSections_RenderConsistently`. +`InMemoryMarkdownWriter_Write_AllOperations_RecordsInOrder`. **Path helper enforces safe path combination**: Verifies that PathHelpers combines valid -relative segments while rejecting traversal attempts, rooted paths, and null input. -This scenario is tested by the `PathHelpers_SafePathCombine_*` test cases in +relative segments while rejecting traversal attempts and null input. Rooted (absolute) +path segments are resolved as relative components within the base directory rather than +rejected. This scenario is tested by the `PathHelpers_SafePathCombine_*` test cases in `PathHelpersTests`. **GlobFileCollector discovers files via glob patterns**: Verifies that GlobFileCollector @@ -71,3 +73,17 @@ for bare-star segments, supports absolute path patterns, removes files matched b exclusion patterns, returns empty results for non-existent roots without throwing, and returns a sorted, deduplicated list. These scenarios are tested by the `GlobFileCollector_Collect_*` test cases in `GlobFileCollectorTests`. + +**Emitter contract is callable through the interface**: Verifies that a language-emitter +implementation compiles against the `IApiEmitter` interface and that `Emit` can be +invoked through an interface reference, producing either multi-file or single-file output +depending on `EmitConfig.Format`. This scenario is tested by +`IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles` and +`IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd`. + +**Emit configuration defaults are correct**: Verifies that `EmitConfig` defaults to +`OutputFormat.GradualDisclosure` for `Format` and `1` for `HeadingDepth`, and that +out-of-range heading depths are rejected. This scenario is tested by +`EmitConfig_DefaultFormat_IsGradualDisclosure`, `EmitConfig_DefaultHeadingDepth_IsOne`, +`EmitConfig_HeadingDepth_BelowMinimum_ThrowsArgumentOutOfRangeException`, and +`EmitConfig_HeadingDepth_AboveMaximum_ThrowsArgumentOutOfRangeException`. diff --git a/docs/verification/api-mark-core/emit-config.md b/docs/verification/api-mark-core/emit-config.md index b258480..c995100 100644 --- a/docs/verification/api-mark-core/emit-config.md +++ b/docs/verification/api-mark-core/emit-config.md @@ -4,15 +4,14 @@ `EmitConfig` is a passive value object with no methods. Verification confirms that its properties are accessible, carry the correct default values, and can be overridden at -construction time using C# `init` setters. Correctness of the `Format` and `HeadingDepth` -properties is validated indirectly through `IApiGeneratorTests`: the stub emitters read -`EmitConfig.Format` to decide how many files to create and `EmitConfig.HeadingDepth` -to determine the heading level, so an incorrectly defaulted or missing property would -cause a test failure. +construction time using C# `init` setters. Default values are now verified directly by +dedicated unit tests (`EmitConfig_DefaultFormat_IsGradualDisclosure` and +`EmitConfig_DefaultHeadingDepth_IsOne`) in addition to indirect validation through the +stub emitter tests. ### Test Environment -N/A — standard test environment using the .NET test runner is sufficient for EmitConfig +N/A - standard test environment using the .NET test runner is sufficient for EmitConfig verification. Property-default compliance is enforced by the test assertions. ### Acceptance Criteria @@ -22,21 +21,40 @@ verification. Property-default compliance is enforced by the test assertions. is supplied. - `EmitConfig.HeadingDepth` defaults to `1` when no explicit value is supplied. - Both properties can be overridden at object-initialization time using `init` setters. +- Setting `HeadingDepth` to a value less than 1 throws `ArgumentOutOfRangeException`. +- Setting `HeadingDepth` to a value greater than 3 throws `ArgumentOutOfRangeException`. +- Setting `HeadingDepth` to a valid non-default value (e.g., `2`) is accepted and + reflected by the property. ### Test Scenarios **GradualDisclosure is the default format**: Verifies that constructing `EmitConfig` with -`new EmitConfig()` and using it in a stub emitter causes multi-file (GradualDisclosure) -output to be produced, confirming the default value of `Format`. This scenario is tested -by `IApiGenerator_Emit_GradualDisclosure_ProducesMultipleFiles`. +`new EmitConfig()` produces `Format == OutputFormat.GradualDisclosure`. This is verified +directly by `EmitConfig_DefaultFormat_IsGradualDisclosure` and also indirectly by +`IApiEmitter_Emit_GradualDisclosure_ProducesMultipleFiles`. **SingleFile format is selectable at construction time**: Verifies that constructing `EmitConfig` with `new EmitConfig { Format = OutputFormat.SingleFile }` and using it in a stub emitter causes exactly one `api.md` to be produced, confirming that the `Format` property can be overridden and that `OutputFormat.SingleFile` is a valid value. This -scenario is tested by `IApiGenerator_Emit_SingleFile_ProducesSingleApiMd`. - -**HeadingDepth defaults to 1**: Verifies that the `SingleFileEmitter` stub reads -`config.HeadingDepth` and passes it to `WriteHeading`, and that no exception is thrown -when the default value (`1`) is used, confirming that the default is a valid heading -level. This scenario is tested by `IApiGenerator_Emit_SingleFile_ProducesSingleApiMd`. +scenario is tested by `IApiEmitter_Emit_SingleFile_ProducesSingleApiMd`. + +**HeadingDepth defaults to 1**: Verifies that `new EmitConfig().HeadingDepth == 1`. This +is verified directly by `EmitConfig_DefaultHeadingDepth_IsOne` and also indirectly by +`IApiEmitter_Emit_SingleFile_ProducesSingleApiMd`. + +**HeadingDepth below minimum throws ArgumentOutOfRangeException**: Verifies that +constructing `new EmitConfig { HeadingDepth = 0 }` throws `ArgumentOutOfRangeException`, +confirming that values below 1 are rejected at init time. This scenario is tested by +`EmitConfig_HeadingDepth_BelowMinimum_ThrowsArgumentOutOfRangeException`. + +**HeadingDepth above maximum throws ArgumentOutOfRangeException**: Verifies that +constructing `new EmitConfig { HeadingDepth = 4 }` throws `ArgumentOutOfRangeException`, +confirming that values above 3 are rejected (because depth 4 would produce H7 member +headings, which are unsupported by CommonMark). This scenario is tested by +`EmitConfig_HeadingDepth_AboveMaximum_ThrowsArgumentOutOfRangeException`. + +**HeadingDepth valid non-default value is accepted**: Verifies that +`new EmitConfig { HeadingDepth = 2 }` succeeds and `HeadingDepth` reflects the +supplied value, confirming the full selectable range. This scenario is tested by +`EmitConfig_HeadingDepth_ValidNonDefault_SetsCorrectly`. diff --git a/docs/verification/api-mark-core/file-markdown-writer-factory.md b/docs/verification/api-mark-core/file-markdown-writer-factory.md new file mode 100644 index 0000000..60aab53 --- /dev/null +++ b/docs/verification/api-mark-core/file-markdown-writer-factory.md @@ -0,0 +1,79 @@ +## FileMarkdownWriterFactory + +### Verification Approach + +`FileMarkdownWriterFactory` is verified through direct unit tests in +`test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs`. Each test creates a +uniquely named temporary directory under `Path.GetTempPath()`, exercises the factory +against the real file system, and deletes the directory in `IDisposable.Dispose` to +prevent test pollution. Tests cover constructor validation, file and directory creation, +and subfolder handling. + +### Test Environment + +N/A - standard .NET test runner is sufficient. Tests use isolated temporary directories +so they are safe to run in parallel. + +### Acceptance Criteria + +- All `FileMarkdownWriterFactoryTests` test cases pass with zero failures. +- Constructor throws `ArgumentException` for null, empty string, and whitespace-only + output directory arguments. +- `CreateMarkdown` throws `ArgumentException` for null, empty string, and + whitespace-only file names. +- `CreateMarkdown` with an empty subFolder creates the file directly under the output root. +- `CreateMarkdown` with a whitespace-only subFolder also creates the file at the output root + (whitespace is treated as equivalent to empty). +- `CreateMarkdown` with a non-empty subFolder creates the required subdirectory and file. +- `CreateMarkdown` creates the output root directory if it does not yet exist at the time + of the call. + +### Test Scenarios + +**Constructor with null output directory throws ArgumentException**: Verifies that +`new FileMarkdownWriterFactory(null!)` throws `ArgumentException`, preventing +misconfiguration at construction time. Tested by +`FileMarkdownWriterFactory_Constructor_NullDirectory_ThrowsArgumentException`. + +**Constructor with empty string output directory throws ArgumentException**: Verifies that +`new FileMarkdownWriterFactory("")` throws `ArgumentException`. Tested by +`FileMarkdownWriterFactory_Constructor_EmptyDirectory_ThrowsArgumentException`. + +**Constructor with whitespace output directory throws ArgumentException**: Verifies that +`new FileMarkdownWriterFactory(" ")` throws `ArgumentException` because whitespace-only +strings are not valid directory paths. Tested by +`FileMarkdownWriterFactory_Constructor_WhitespaceDirectory_ThrowsArgumentException`. + +**CreateMarkdown with null name throws ArgumentException**: Verifies that +`factory.CreateMarkdown("", null!)` throws `ArgumentException` before any I/O is +attempted, so the misuse is immediately attributable. Tested by +`FileMarkdownWriterFactory_CreateMarkdown_NullName_ThrowsArgumentException`. + +**CreateMarkdown with empty name throws ArgumentException**: Verifies that +`factory.CreateMarkdown("", "")` throws `ArgumentException`. Tested by +`FileMarkdownWriterFactory_CreateMarkdown_EmptyName_ThrowsArgumentException`. + +**CreateMarkdown with whitespace name throws ArgumentException**: Verifies that +`factory.CreateMarkdown("", " ")` throws `ArgumentException`. Tested by +`FileMarkdownWriterFactory_CreateMarkdown_WhitespaceName_ThrowsArgumentException`. + +**CreateMarkdown with empty subFolder creates root-level file**: Verifies that passing +an empty string as `subFolder` places the output file directly under the configured +output root and that the file exists after the writer is disposed. Tested by +`FileMarkdownWriterFactory_CreateMarkdown_RootLevel_CreatesFile`. + +**CreateMarkdown with whitespace subFolder creates root-level file**: Verifies that +passing a whitespace-only string as `subFolder` is treated the same as an empty string, +placing the output file directly under the configured output root. This matches the +design contract which states that null or whitespace in `subFolder` routes to the root. +Tested by `FileMarkdownWriterFactory_CreateMarkdown_WhitespaceSubFolder_CreatesRootLevelFile`. + +**CreateMarkdown with subFolder creates directory and file**: Verifies that a non-empty +`subFolder` causes the factory to create the required subdirectory and write the file +inside it. Tested by +`FileMarkdownWriterFactory_CreateMarkdown_WithSubFolder_CreatesDirectoryAndFile`. + +**CreateMarkdown creates non-existent output directory on first use**: Verifies that the +factory creates the output root directory when it does not yet exist at the time of the +first `CreateMarkdown` call, without requiring callers to pre-create directories. Tested +by `FileMarkdownWriterFactory_CreateMarkdown_NonExistentDirectory_CreatesDirectory`. diff --git a/docs/verification/api-mark-core/file-markdown-writer.md b/docs/verification/api-mark-core/file-markdown-writer.md new file mode 100644 index 0000000..d02f7fc --- /dev/null +++ b/docs/verification/api-mark-core/file-markdown-writer.md @@ -0,0 +1,168 @@ +## FileMarkdownWriter + +### Verification Approach + +`FileMarkdownWriter` is `internal sealed` and is exercised through +`FileMarkdownWriterFactory` in `test/ApiMark.Core.Tests/FileMarkdownWriterTests.cs` +and `test/ApiMark.Core.Tests/IMarkdownWriterTests.cs`. Tests create a temporary +directory, write content through the factory, then read the resulting file and assert +on its contents. Disposal behavior is verified by confirming that the file handle is +released after `Dispose()` is called. + +### Test Environment + +N/A - standard .NET test runner is sufficient. Tests use isolated temporary directories +and clean up in `IDisposable.Dispose` to prevent test pollution. + +### Acceptance Criteria + +- All `FileMarkdownWriterTests` and relevant `IMarkdownWriterTests` test cases pass with + zero failures. +- `WriteHeading` at level 1 produces `# {text}` and at level 3 produces `### {text}`. +- `WriteHeading` throws `ArgumentOutOfRangeException` for level 0 and level 7. +- All write methods throw `ObjectDisposedException` when called after `Dispose`. +- `WriteSignature` produces a fenced code block with the supplied language tag and code; + throws `ArgumentNullException` for null language or code. +- `WriteParagraph` writes the supplied text to the file; throws `ArgumentNullException` + for null text. +- `WriteTable` produces a pipe-delimited GFM table with a header row, separator row, and + data rows; pipe characters in cell values are escaped as `\|`; throws + `ArgumentNullException` for null headers or null rows; throws `ArgumentException` for + an empty headers array, any null element in the headers array, any null row element in the + rows sequence, any null cell element within a row, or any row whose column count differs + from the header count. +- `WriteCodeBlock` produces a fenced code block equivalent to `WriteSignature`; throws + `ArgumentNullException` for null language or code. +- `WriteLink` writes an inline Markdown link `[text](path)`; throws `ArgumentNullException` + for null text or null relativePath; throws `ArgumentException` for empty text. +- `WriteHeading` throws `ArgumentException` for empty text in addition to + `ArgumentOutOfRangeException` for out-of-range levels. +- After `Dispose` is called the file handle is released and the file content is + accessible to other readers. + +### Test Scenarios + +**WriteHeading at level 1 produces single-# ATX heading**: Verifies that +`WriteHeading(1, "My Heading")` writes `# My Heading` to the file. Tested by +`FileMarkdownWriter_WriteHeading_Level1_WritesCorrectMarkdown`. + +**WriteHeading at level 3 produces triple-# ATX heading**: Verifies that +`WriteHeading(3, "My Heading")` writes `### My Heading` to the file. Tested by +`FileMarkdownWriter_WriteHeading_Level3_WritesCorrectMarkdown`. + +**WriteSignature produces fenced code block with language tag**: Verifies that +`WriteSignature("csharp", "public void DoWork();")` emits a fenced block opening +with ` ```csharp ` and the code on the next line. Tested by +`FileMarkdownWriter_WriteSignature_ValidArgs_WritesCodeFence`. + +**WriteParagraph writes paragraph text**: Verifies that the paragraph body text appears +verbatim in the output file. Tested by +`FileMarkdownWriter_WriteParagraph_ValidText_WritesParagraphText`. + +**WriteTable produces pipe-delimited GFM table**: Verifies that the header row, +separator row, and data rows are all written with correct pipe delimiters. Tested by +`FileMarkdownWriter_WriteTable_ValidArgs_WritesPipeTable`. + +**WriteTable escapes pipe characters in cell values**: Verifies that a cell value +containing a literal `|` character is written as `\|` in the output, preventing +table structure breakage. Tested by `FileMarkdownWriter_WriteTable_CellWithPipe_EscapesPipe`. + +**WriteCodeBlock produces fenced code block**: Verifies that `WriteCodeBlock` produces +the same fenced-block output as `WriteSignature`. Tested by +`FileMarkdownWriter_WriteCodeBlock_ValidArgs_WritesCodeFence`. + +**WriteLink writes inline Markdown link**: Verifies that `WriteLink("Back to Index", "../api.md")` +produces `[Back to Index](../api.md)` in the file. Tested by +`FileMarkdownWriter_WriteLink_ValidArgs_WritesMarkdownLink`. + +**Dispose flushes content and releases file handle**: Verifies that after `Dispose()` is +called the file is non-empty and can be opened with exclusive read access by another +caller, confirming that content was flushed and the handle was released. Tested by +`FileMarkdownWriter_Dispose_AfterWrite_FlushesAndClosesFile`. + +**WriteHeading rejects level below 1**: Verifies that `WriteHeading(0, ...)` throws +`ArgumentOutOfRangeException`, enforcing the CommonMark lower bound. Tested by +`FileMarkdownWriter_WriteHeading_ZeroLevel_ThrowsArgumentOutOfRangeException`. + +**WriteHeading rejects level above 6**: Verifies that `WriteHeading(7, ...)` throws +`ArgumentOutOfRangeException`, enforcing the CommonMark upper bound. Tested by +`FileMarkdownWriter_WriteHeading_SevenLevel_ThrowsArgumentOutOfRangeException`. + +**Any write method after Dispose throws ObjectDisposedException**: Verifies that every +write method throws `ObjectDisposedException` when called after `Dispose`, confirming that +the disposed guard is applied uniformly across the entire writer API. Tested by: +`FileMarkdownWriter_WriteHeading_AfterDispose_ThrowsObjectDisposedException`, +`FileMarkdownWriter_WriteSignature_AfterDispose_ThrowsObjectDisposedException`, +`FileMarkdownWriter_WriteParagraph_AfterDispose_ThrowsObjectDisposedException`, +`FileMarkdownWriter_WriteTable_AfterDispose_ThrowsObjectDisposedException`, +`FileMarkdownWriter_WriteCodeBlock_AfterDispose_ThrowsObjectDisposedException`, +`FileMarkdownWriter_WriteLink_AfterDispose_ThrowsObjectDisposedException`. + +**WriteSignature rejects null language**: Verifies that `WriteSignature(null!, ...)` throws +`ArgumentNullException`. Tested by +`FileMarkdownWriter_WriteSignature_NullLanguage_ThrowsArgumentNullException`. + +**WriteSignature rejects null code**: Verifies that `WriteSignature("csharp", null!)` throws +`ArgumentNullException`. Tested by +`FileMarkdownWriter_WriteSignature_NullCode_ThrowsArgumentNullException`. + +**WriteParagraph rejects null text**: Verifies that `WriteParagraph(null!)` throws +`ArgumentNullException`. Tested by +`FileMarkdownWriter_WriteParagraph_NullText_ThrowsArgumentNullException`. + +**WriteTable rejects null headers**: Verifies that `WriteTable(null!, ...)` throws +`ArgumentNullException`. Tested by +`FileMarkdownWriter_WriteTable_NullHeaders_ThrowsArgumentNullException`. + +**WriteTable rejects null rows**: Verifies that `WriteTable([], null!)` throws +`ArgumentNullException`, enforcing the null contract on the rows parameter. Tested by +`FileMarkdownWriter_WriteTable_NullRows_ThrowsArgumentNullException`. + +**WriteCodeBlock rejects null language**: Verifies that `WriteCodeBlock(null!, ...)` throws +`ArgumentNullException`. Tested by +`FileMarkdownWriter_WriteCodeBlock_NullLanguage_ThrowsArgumentNullException`. + +**WriteCodeBlock rejects null code**: Verifies that `WriteCodeBlock("csharp", null!)` throws +`ArgumentNullException`. Tested by +`FileMarkdownWriter_WriteCodeBlock_NullCode_ThrowsArgumentNullException`. + +**WriteLink rejects null text**: Verifies that `WriteLink(null!, ...)` throws +`ArgumentNullException`. Tested by +`FileMarkdownWriter_WriteLink_NullText_ThrowsArgumentNullException`. + +**WriteLink rejects null relativePath**: Verifies that `WriteLink("Back", null!)` throws +`ArgumentNullException`. Tested by +`FileMarkdownWriter_WriteLink_NullRelativePath_ThrowsArgumentNullException`. + +**WriteTable rejects empty headers**: Verifies that `WriteTable([], [])` throws +`ArgumentException`, enforcing that a headerless table is not valid GFM syntax. Tested by +`FileMarkdownWriter_WriteTable_EmptyHeaders_ThrowsArgumentException`. + +**WriteTable rejects mismatched row column count**: Verifies that calling `WriteTable` +with a row whose cell count differs from the header count throws `ArgumentException`, +preventing silent generation of a structurally malformed pipe table. Tested by +`FileMarkdownWriter_WriteTable_MismatchedRowLength_ThrowsArgumentException`. + +**WriteHeading rejects empty text**: Verifies that `WriteHeading(1, "")` throws +`ArgumentException`, enforcing that a heading with no label is not valid Markdown. Tested by +`FileMarkdownWriter_WriteHeading_EmptyText_ThrowsArgumentException`. + +**WriteLink rejects empty text**: Verifies that `WriteLink("", "api.md")` throws +`ArgumentException`, enforcing that a link with no visible label is not valid navigation +markup. Tested by `FileMarkdownWriter_WriteLink_EmptyText_ThrowsArgumentException`. + +**WriteTable rejects null row element**: Verifies that passing a rows sequence containing +a null element (e.g. `[["a","b"], null, ["c","d"]]`) throws `ArgumentException` before +any output is written, preventing a `NullReferenceException` in rendering logic. Empty +cell content (e.g. `""`) remains valid. Tested by +`FileMarkdownWriter_WriteTable_NullRowElement_ThrowsArgumentException`. + +**WriteTable rejects null header element**: Verifies that passing a headers array containing +a null element (e.g. `["Name", null, "Description"]`) throws `ArgumentException` before any +output is written, preventing a `NullReferenceException` in rendering logic. Tested by +`FileMarkdownWriter_WriteTable_NullHeaderElement_ThrowsArgumentException`. + +**WriteTable rejects null cell element**: Verifies that passing a row containing a null cell +(e.g. `[["value", null]]`) throws `ArgumentException` before any output is written, +preventing a `NullReferenceException` in rendering logic. Tested by +`FileMarkdownWriter_WriteTable_NullCellElement_ThrowsArgumentException`. diff --git a/docs/verification/api-mark-core/glob-file-collector.md b/docs/verification/api-mark-core/glob-file-collector.md index de78d59..75f3cb0 100644 --- a/docs/verification/api-mark-core/glob-file-collector.md +++ b/docs/verification/api-mark-core/glob-file-collector.md @@ -10,7 +10,7 @@ deleted in `finally` blocks to prevent test pollution. ### Test Environment -N/A — standard .NET test runner is sufficient. No external tools or environment +N/A - standard .NET test runner is sufficient. No external tools or environment variables are required. Tests use isolated temporary directories so they are safe to run in parallel. @@ -22,9 +22,13 @@ safe to run in parallel. - Bare-star patterns (`**/*`) apply language-extension filtering. - Explicit-extension patterns (`**/*.vhd`) select only that extension. - Absolute patterns are supported and ignore the working directory. +- Absolute patterns with no glob metacharacters are treated as literal file paths + and select exactly the named file when it exists and has a matching extension. - `!`-prefixed exclusion patterns remove matching files from the result. - Non-existent pattern roots return an empty result without throwing. - Overlapping patterns produce a deduplicated, sorted result. +- `Collect` throws `ArgumentNullException` for null `patterns`, `languageExtensions`, + or `workingDirectory`. ### Test Scenarios @@ -64,3 +68,26 @@ supplying two identical patterns that both match the same files returns each file exactly once, and that the returned list is in ascending ordinal order. Tested by `GlobFileCollector_Collect_OverlappingPatterns_ReturnsSortedDeduplicated`. + +**Literal absolute file path selects exactly the named file**: Verifies that an +absolute pattern with no glob metacharacters (e.g., `/absolute/path/to/file.vhd`) +is treated as a literal file path and returned in the result when the file exists +and has a matching extension, without any directory traversal. Tested by +`GlobFileCollector_Collect_LiteralAbsoluteFilePath_SelectsExactFile`. + +**Literal absolute exclusion path removes exactly the named file**: Verifies that +an absolute literal exclusion pattern (no glob metacharacters, prefixed with `!`) +removes exactly the named file from the accumulated inclusion result. Tested by +`GlobFileCollector_Collect_LiteralAbsoluteExclusionPath_RemovesExactFile`. + +**Null patterns throws ArgumentNullException**: Verifies that passing `null` for the +`patterns` argument throws `ArgumentNullException` immediately. Tested by +`GlobFileCollector_Collect_NullPatterns_ThrowsArgumentNullException`. + +**Null languageExtensions throws ArgumentNullException**: Verifies that passing `null` +for the `languageExtensions` argument throws `ArgumentNullException` immediately. Tested +by `GlobFileCollector_Collect_NullLanguageExtensions_ThrowsArgumentNullException`. + +**Null workingDirectory throws ArgumentNullException**: Verifies that passing `null` +for the `workingDirectory` argument throws `ArgumentNullException` immediately. Tested +by `GlobFileCollector_Collect_NullWorkingDirectory_ThrowsArgumentNullException`. diff --git a/docs/verification/api-mark-core/i-api-emitter.md b/docs/verification/api-mark-core/i-api-emitter.md index 4a278d6..68ce9dd 100644 --- a/docs/verification/api-mark-core/i-api-emitter.md +++ b/docs/verification/api-mark-core/i-api-emitter.md @@ -4,13 +4,15 @@ `IApiEmitter` is a contract interface. Verification confirms that all language-emitter implementations compile and pass type-checking against the interface, and that the -contract behavior is validated through `IApiGeneratorTests`: if the interface signature -is wrong, the consuming implementation fails to compile and the tests cannot run. No -additional mocking is required because `IApiEmitter` carries no logic of its own. +contract behavior is validated through dedicated `IApiEmitterTests` covering format +selection and the `api.md` entrypoint contract. If the interface signature is wrong, +the consuming implementation fails to compile and the tests cannot run. Supporting +coverage is also provided by `IApiGeneratorTests`, which exercises the same code path +end-to-end. ### Test Environment -N/A — standard test environment using the .NET test runner is sufficient for IApiEmitter +N/A - standard test environment using the .NET test runner is sufficient for IApiEmitter verification. Interface contract compliance is enforced at compile time. ### Acceptance Criteria @@ -26,24 +28,44 @@ verification. Interface contract compliance is enforced at compile time. **IApiEmitter.Emit is callable through the interface reference**: Verifies that a minimal inline stub of `IApiEmitter` can be implemented and its `Emit` method called with a -factory, config, and context argument. This scenario is tested by -`IApiGenerator_Parse_WithMinimalStub_ExecutesSuccessfully` and -`IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles`. +factory, config, and context argument. This scenario is tested primarily by +`IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles` and +`IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd`; supplemental coverage is +provided by `IApiGenerator_Parse_WithMinimalStub_ExecutesSuccessfully`. **GradualDisclosure format produces multiple files**: Verifies that when `EmitConfig.Format` is `GradualDisclosure`, calling `Emit` causes the factory to receive more than one `CreateMarkdown` call, confirming that the gradual-disclosure multi-file contract is -honored. This scenario is tested by `IApiGenerator_Emit_GradualDisclosure_ProducesMultipleFiles` +honored. This scenario is tested by `IApiEmitter_Emit_GradualDisclosure_ProducesMultipleFiles` and `IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles`. **SingleFile format produces only api.md**: Verifies that when `EmitConfig.Format` is `SingleFile`, calling `Emit` causes the factory to receive exactly one `CreateMarkdown` call writing only `api.md` at the root, confirming that the single-file consolidation -contract is honored. This scenario is tested by `IApiGenerator_Emit_SingleFile_ProducesSingleApiMd` and +contract is honored. This scenario is tested by `IApiEmitter_Emit_SingleFile_ProducesSingleApiMd` and `IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd`. +**Format-aware emitter reads config.Format to determine output shape**: Verifies that a +single `FormatAwareStubEmitter` implementation correctly produces multi-file output when +`config.Format` is `GradualDisclosure` and single-file output when `config.Format` is +`SingleFile`, confirming that the format-selection decision belongs at the `IApiEmitter` +level. These scenarios are tested by `IApiEmitter_Emit_GradualDisclosure_ProducesMultipleFiles` +and `IApiEmitter_Emit_SingleFile_ProducesSingleApiMd`. + **api.md entrypoint is always produced**: Verifies that every conformant `IApiEmitter` implementation calls `factory.CreateMarkdown("", "api")` to produce the fixed top-level entrypoint required by all callers. This scenario is tested by `IApiGenerator_Emit_OutputDirectory_ContainsApiMd`, `IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles`, and `IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd`. + +**Emit rejects null factory**: Verifies that `IApiEmitter.Emit` throws `ArgumentNullException` +when `null` is passed for the factory argument, confirming the null-precondition contract at +the interface level. Tested by `IApiEmitter_Emit_NullFactory_ThrowsArgumentNullException`. + +**Emit rejects null config**: Verifies that `IApiEmitter.Emit` throws `ArgumentNullException` +when `null` is passed for the config argument, confirming the null-precondition contract at +the interface level. Tested by `IApiEmitter_Emit_NullConfig_ThrowsArgumentNullException`. + +**Emit rejects null context**: Verifies that `IApiEmitter.Emit` throws `ArgumentNullException` +when `null` is passed for the context argument, confirming the null-precondition contract at +the interface level. Tested by `IApiEmitter_Emit_NullContext_ThrowsArgumentNullException`. diff --git a/docs/verification/api-mark-core/i-api-generator.md b/docs/verification/api-mark-core/i-api-generator.md index bfbcadc..bcdc5f8 100644 --- a/docs/verification/api-mark-core/i-api-generator.md +++ b/docs/verification/api-mark-core/i-api-generator.md @@ -13,7 +13,7 @@ implementation. ### Test Environment -N/A — standard test environment using the .NET test runner is sufficient for IApiGenerator +N/A - standard test environment using the .NET test runner is sufficient for IApiGenerator verification. Interface contract compliance is enforced at compile time. ### Acceptance Criteria @@ -27,14 +27,15 @@ verification. Interface contract compliance is enforced at compile time. **All generator implementations satisfy the IApiGenerator contract**: Verifies that each language-generator class compiles against the `IApiGenerator` interface and can be assigned to an -`IApiGenerator` reference without a cast, proving the contract is fulfilled and usable by host -tooling without knowing the concrete type. This scenario is tested by +`IApiGenerator` reference without a cast, and that `Parse` accepts an `IContext` parameter as the +diagnostic channel without throwing. This scenario is tested by `IApiGenerator_Parse_WithMinimalStub_ExecutesSuccessfully`. -**Contract methods are invocable through the interface reference**: Verifies that calling the -`Parse` method through an `IApiGenerator` reference dispatches correctly and does not throw for -the expected inputs, confirming that the interface contract is invocable end-to-end. This scenario -is tested by `ApiMarkCore_GeneratorContract_SupportedLanguage_CanBeInvoked`. +**Parse returns an IApiEmitter that is callable through the interface reference**: Verifies that +calling `Parse` through an `IApiGenerator` reference returns an `IApiEmitter` that can subsequently +be invoked with `Emit`, confirming that the full dispatch path from `Parse` to `Emit` is +end-to-end callable. This scenario is tested by +`ApiMarkCore_GeneratorContract_SupportedLanguage_CanBeInvoked`. **Implementation retains construction-time configuration during Parse**: Verifies that a language-generator implementation that stores a configuration value at construction time can @@ -42,15 +43,14 @@ access that value inside `Parse`, confirming that the interface permits construc injection of options. This scenario is tested by `IApiGenerator_Implementation_UsesConstructionConfiguration`. -**Parse produces the mandatory api.md root entrypoint**: Verifies that a conformant -`IApiGenerator` implementation calls `factory.CreateMarkdown("", "api")` to produce the fixed -top-level entrypoint required by all callers. This scenario is tested by -`IApiGenerator_Emit_OutputDirectory_ContainsApiMd`. - -**IApiEmitter.Emit supports configurable output format**: Verifies that when -`EmitConfig.Format` is `GradualDisclosure`, calling `Emit` through the `IApiGenerator` -interface causes the factory to receive more than one `CreateMarkdown` call (multi-file -output), and that when `EmitConfig.Format` is `SingleFile`, only one `CreateMarkdown` call -is made writing `api.md`. This scenario is tested by -`IApiGenerator_Emit_GradualDisclosure_ProducesMultipleFiles` and -`IApiGenerator_Emit_SingleFile_ProducesSingleApiMd`. +**Format-selection and api.md entrypoint are responsibilities of IApiEmitter**: The format-selection +contract (GradualDisclosure produces multiple files; SingleFile produces one api.md) and the +mandatory `api.md` entrypoint production belong at the `IApiEmitter` level and are verified in +`docs/verification/api-mark-core/i-api-emitter.md`. +`IApiGenerator` implementations delegate both concerns to their returned `IApiEmitter`. + +**Parse rejects null context**: Verifies that an `IApiGenerator` implementation throws +`ArgumentNullException` when `null` is passed as the context argument to `Parse`, confirming +the null-precondition contract at the interface level. The test uses a minimal stub generator +that calls `ArgumentNullException.ThrowIfNull(context)` before any other work to document +the expected behavior. Tested by `IApiGenerator_Parse_NullContext_ThrowsArgumentNullException`. diff --git a/docs/verification/api-mark-core/i-context.md b/docs/verification/api-mark-core/i-context.md index c5927ef..28befd6 100644 --- a/docs/verification/api-mark-core/i-context.md +++ b/docs/verification/api-mark-core/i-context.md @@ -11,7 +11,7 @@ after each call. ### Test Environment -N/A — standard test environment using the .NET test runner is sufficient for +N/A - standard test environment using the .NET test runner is sufficient for IContext verification. Interface contract compliance is enforced at compile time. ### Acceptance Criteria @@ -23,6 +23,10 @@ IContext verification. Interface contract compliance is enforced at compile time call order. - Messages written to `WriteLine` do not appear in `Errors`, and messages written to `WriteError` do not appear in `Lines`. +- `InMemoryContext.WriteLine` throws `ArgumentNullException` when passed null. +- `InMemoryContext.WriteError` throws `ArgumentNullException` when passed null. +- Messages written to both channels in a defined sequence appear in that exact order + within each channel, with no reordering. ### Test Scenarios @@ -42,6 +46,17 @@ appear only in `Errors` when both channels are exercised on the same instance, confirming channel isolation. This scenario is tested by `InMemoryContext_WriteLineAndWriteError_RouteToSeparateChannels`. -> **Note on null-message boundary**: No boundary scenario is included for null `message` -> — the `IContext` interface contract defines no error path for null input; -> `InMemoryContext` performs no null validation. +**WriteLine rejects null message**: Verifies that passing null to `InMemoryContext.WriteLine` +throws `ArgumentNullException`, enforcing the null contract specified on the `IContext` +interface (`IContext-RejectNullWriteLine`). This scenario is tested by +`InMemoryContext_WriteLine_NullMessage_ThrowsArgumentNullException`. + +**WriteError rejects null message**: Verifies that passing null to `InMemoryContext.WriteError` +throws `ArgumentNullException`, enforcing the null contract specified on the `IContext` +interface (`IContext-RejectNullWriteError`). This scenario is tested by +`InMemoryContext_WriteError_NullMessage_ThrowsArgumentNullException`. + +**Multiple messages maintain call order**: Verifies that interleaved calls to both +`WriteLine` and `WriteError` accumulate messages in the exact order they were written, +with no reordering between or within channels. This scenario is tested by +`InMemoryContext_MultipleMessages_MaintainCallOrder`. diff --git a/docs/verification/api-mark-core/i-markdown-writer-factory.md b/docs/verification/api-mark-core/i-markdown-writer-factory.md index ee53122..40a569f 100644 --- a/docs/verification/api-mark-core/i-markdown-writer-factory.md +++ b/docs/verification/api-mark-core/i-markdown-writer-factory.md @@ -15,7 +15,7 @@ temporary directory per test run to avoid inter-test interference. ### Test Environment -N/A — standard test environment using the .NET test runner is sufficient. +N/A - standard test environment using the .NET test runner is sufficient. Interface contract compliance is enforced at compile time through the test double in `ApiMark.Core.TestHelpers`. @@ -54,6 +54,12 @@ string for subFolder produces a root-level writer (i.e. no subdirectory prefix i the captured path). Tested by `IMarkdownWriterFactory_CreateMarkdown_EmptySubFolder_IsRootLevel`. +**Root-level file created with whitespace-only subFolder**: Verifies that passing +a whitespace-only string for subFolder produces a root-level writer — whitespace is +treated identically to an empty string so that generators are not required to trim +configuration values before calling CreateMarkdown. Tested by +`FileMarkdownWriterFactory_CreateMarkdown_WhitespaceSubFolder_CreatesRootLevelFile`. + **File-system factory creates root-level file on disk**: Verifies that `FileMarkdownWriterFactory.CreateMarkdown` with an empty subFolder writes a real file directly under the output root directory and that the file exists after the diff --git a/docs/verification/api-mark-core/i-markdown-writer.md b/docs/verification/api-mark-core/i-markdown-writer.md index b617753..48deb27 100644 --- a/docs/verification/api-mark-core/i-markdown-writer.md +++ b/docs/verification/api-mark-core/i-markdown-writer.md @@ -10,7 +10,7 @@ forwarded with the correct arguments and in the correct order. ### Test Environment -N/A — standard test environment using the .NET test runner is sufficient. +N/A - standard test environment using the .NET test runner is sufficient. Interface contract compliance is enforced at compile time through the test double in `ApiMark.Core.TestHelpers`. @@ -45,7 +45,7 @@ contract is correctly implemented by the test double. Tested by consumers can call heading, signature, paragraph, table, code block, and link operations through an `IMarkdownWriter` reference and that the recorded invocations carry the correct level, text, and path arguments. Tested by -`InMemoryMarkdownWriter_Write_AllMethods_RecordsOperations`. +`InMemoryMarkdownWriter_Write_AllOperations_RecordsInOrder`. **Writer contract captures output in declaration order**: Verifies that successive write operations through the `IMarkdownWriter` interface are recorded in the sequence diff --git a/docs/verification/api-mark-core/path-helpers.md b/docs/verification/api-mark-core/path-helpers.md index 69f415e..3c73bc1 100644 --- a/docs/verification/api-mark-core/path-helpers.md +++ b/docs/verification/api-mark-core/path-helpers.md @@ -18,6 +18,7 @@ N/A - standard test environment using the .NET test runner is sufficient. - `SafePathCombine` returns the expected combined path for valid input, including nested paths, current-directory references, empty segments, multi-segment combinations, segments that backtrack within the base, and filenames containing `..` as a substring. +- `SafePathCombine` returns `basePath` unchanged when zero segments are supplied. - `SafePathCombine` throws `ArgumentException` for paths that resolve outside the base directory after joining. - `SafePathCombine` throws `ArgumentNullException` for null `basePath` and null segments. @@ -68,3 +69,7 @@ null base path is rejected with `ArgumentNullException`. **PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullException**: Verifies that a null segment is rejected with `ArgumentNullException`. + +**PathHelpers_SafePathCombine_NoSegments_ReturnsBasePath**: Verifies that calling +`SafePathCombine` with zero segments returns `basePath` unchanged, confirming +that the zero-segment case is handled as a no-op. diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index a5eb98d..0992780 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -81,6 +81,32 @@ Local items: - **Validation**: unit verification of the self-test execution logic. - **Program**: unit verification of CLI argument parsing, language dispatch, and error handling. +Out of scope: verification documents are not produced for the test projects themselves — +they are the means of verification, not subjects of it. Build pipeline CI configuration +and the internal implementation of OTS items are also excluded. + +## Folder Layout + +```text +test/ +├── ApiMark.Core.TestHelpers/ +│ ├── InMemoryMarkdownWriterFactory.cs - in-memory IMarkdownWriterFactory test double +│ ├── InMemoryMarkdownWriter.cs - in-memory IMarkdownWriter test double +│ └── InMemoryContext.cs - in-memory IContext test double +│ +│ (Test Runners) +├── ApiMark.Core.Tests/ - unit tests for Core contracts +├── ApiMark.DotNet.Tests/ - unit tests for DotNetGenerator and TypeNameSimplifier +├── ApiMark.Cpp.Tests/ - unit tests for CppGenerator +├── ApiMark.Vhdl.Tests/ - unit tests for VhdlGenerator +├── ApiMark.MSBuild.Tests/ - unit tests for ApiMarkTask +├── ApiMark.Tool.Tests/ - integration tests for the CLI tool +│ +│ (Fixtures — provide test inputs only; not test runners) +├── ApiMark.DotNet.Fixtures/ - multi-target fixture assembly for DotNet integration tests +└── ApiMark.Cpp.Fixtures/ - C++ fixture headers for CppGenerator integration tests +``` + OTS items: - **Mono.Cecil**: integration verification that assembly-reading and metadata APIs used by @@ -92,6 +118,9 @@ OTS items: - **Antlr4.Runtime.Standard / ANTLR4 vhdl2008 grammar**: integration verification that the committed generated parser code correctly parses VHDL-2008 source files, verified indirectly through the VhdlAstParser test suite. +- **Microsoft.Extensions.FileSystemGlobbing**: integration verification that the glob + pattern matching APIs used by GlobFileCollector correctly match and exclude files, + verified indirectly through the GlobFileCollector test suite. - **cpp-ast-net** (archived): retained for historical reference; see clang verification for the current approach. @@ -216,25 +245,26 @@ Local items have parallel artifacts in: `docs/verification/api-mark-tool/self-test/validation.md` - Source: `src/ApiMark.Core/`, `src/ApiMark.Cpp/`, `src/ApiMark.DotNet/`, `src/ApiMark.Vhdl/`, `src/ApiMark.MSBuild/`, `src/ApiMark.Tool/` -- Tests: `test/ApiMark.Core.Tests/`, `test/ApiMark.Cpp.Fixtures/`, - `test/ApiMark.Cpp.Tests/`, `test/ApiMark.DotNet.Tests/`, `test/ApiMark.Vhdl.Tests/`, +- Tests: `test/ApiMark.Core.Tests/`, `test/ApiMark.Cpp.Tests/`, + `test/ApiMark.DotNet.Tests/`, `test/ApiMark.Vhdl.Tests/`, `test/ApiMark.MSBuild.Tests/`, `test/ApiMark.Tool.Tests/` +- Fixtures: `test/ApiMark.DotNet.Fixtures/`, `test/ApiMark.Cpp.Fixtures/` OTS items have integration and usage artifacts parallel to the system folders: - Requirements: `docs/reqstream/ots/clang.yaml`, `docs/reqstream/ots/mono-cecil.yaml`, `docs/reqstream/ots/dema-consulting-test-results.yaml`, `docs/reqstream/ots/antlr4.yaml`, - `docs/reqstream/ots/cpp-ast-net.yaml` + `docs/reqstream/ots/file-system-globbing.yaml`, `docs/reqstream/ots/cpp-ast-net.yaml` - Design: `docs/design/ots/clang.md`, `docs/design/ots/mono-cecil.md`, `docs/design/ots/dema-consulting-test-results.md`, `docs/design/ots/antlr4.md`, - `docs/design/ots/cpp-ast-net.md` + `docs/design/ots/file-system-globbing.md`, `docs/design/ots/cpp-ast-net.md` - Verification: `docs/verification/ots/clang.md`, `docs/verification/ots/mono-cecil.md`, `docs/verification/ots/dema-consulting-test-results.md`, `docs/verification/ots/antlr4.md`, - `docs/verification/ots/cpp-ast-net.md` + `docs/verification/ots/file-system-globbing.md`, `docs/verification/ots/cpp-ast-net.md` Review-sets are defined in `.reviewmark.yaml`. ## References -N/A — no external specification is required to describe this repository-specific verification +N/A - no external specification is required to describe this repository-specific verification baseline. diff --git a/docs/verification/ots.md b/docs/verification/ots.md index cdec872..a0db806 100644 --- a/docs/verification/ots.md +++ b/docs/verification/ots.md @@ -61,3 +61,24 @@ invokes the parser. See `docs/verification/ots/antlr4.md` for detailed test scen directories and confirm that the `Matcher` API correctly handles include patterns, exclude patterns, and non-existent roots. See `docs/verification/ots/file-system-globbing.md` for detailed test scenarios. + +## DemaConsulting.TestResults + +`DemaConsulting.TestResults` is verified through the self-validation tests in +`test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs`. These tests exercise the +pass/fail outcome recording and results-file serialization paths of `Validation.cs`, +which is the sole consumer of `DemaConsulting.TestResults` in the repository. The +verification focus is the subset of capabilities the product depends on: creating a +`TestResults` collection, recording `Passed` and `Failed` outcomes on individual +`TestResult` objects, and serializing the collection to TRX and JUnit XML via +`TrxSerializer` and `JUnitSerializer`. See +`docs/verification/ots/dema-consulting-test-results.md` for detailed test scenarios. + +## cpp-ast-net (Archived) + +The `cpp-ast-net` OTS item is archived and retained for historical reference only. It was +superseded by direct `clang -ast-dump=json` invocation (see the *clang* section above for the +current approach). No active verification scenarios exist for this item; the design, requirements, +and verification documents (`docs/design/ots/cpp-ast-net.md`, `docs/reqstream/ots/cpp-ast-net.yaml`, +`docs/verification/ots/cpp-ast-net.md`) are preserved to document the historical integration +decision. diff --git a/requirements.yaml b/requirements.yaml index 2c17b6c..f1d3d9a 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -7,7 +7,9 @@ includes: - docs/reqstream/api-mark-core/emit-config.yaml - docs/reqstream/api-mark-core/i-context.yaml - docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml + - docs/reqstream/api-mark-core/file-markdown-writer-factory.yaml - docs/reqstream/api-mark-core/i-markdown-writer.yaml + - docs/reqstream/api-mark-core/file-markdown-writer.yaml - docs/reqstream/api-mark-core/path-helpers.yaml - docs/reqstream/api-mark-core/glob-file-collector.yaml - docs/reqstream/api-mark-dot-net.yaml diff --git a/src/ApiMark.Core/EmitConfig.cs b/src/ApiMark.Core/EmitConfig.cs index 27fa415..dc1f2e0 100644 --- a/src/ApiMark.Core/EmitConfig.cs +++ b/src/ApiMark.Core/EmitConfig.cs @@ -15,11 +15,31 @@ public sealed class EmitConfig /// public OutputFormat Format { get; init; } = OutputFormat.GradualDisclosure; + /// The backing field for . + private int _headingDepth = 1; + /// - /// Gets the heading depth offset for the top-level heading in + /// Gets or inits the heading depth offset for the top-level heading in /// output. A value of 1 (the default) /// produces an H1 top-level heading; a value of 2 produces H2, and so on. /// Only used when is . + /// Valid range: 1–3. At depth 3, member headings reach H6 (the maximum in + /// Markdown); values above 3 would produce H7+ headings which are not defined + /// by CommonMark. /// - public int HeadingDepth { get; init; } = 1; + /// + /// Thrown when the supplied value is less than 1 or greater than 3. + /// + public int HeadingDepth + { + get => _headingDepth; + init + { + // Enforce the 1–3 range so that the effective member heading level + // (HeadingDepth + 3) stays within the H1–H6 range supported by Markdown + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 3); + _headingDepth = value; + } + } } diff --git a/src/ApiMark.Core/FileMarkdownWriter.cs b/src/ApiMark.Core/FileMarkdownWriter.cs index f95f22c..26405bb 100644 --- a/src/ApiMark.Core/FileMarkdownWriter.cs +++ b/src/ApiMark.Core/FileMarkdownWriter.cs @@ -40,8 +40,10 @@ internal FileMarkdownWriter(StreamWriter writer) /// invalid; values above 6 are not defined by CommonMark and will not render /// as headings in standard Markdown processors. /// - /// Heading text to display. Must not be null. + /// Heading text to display. Must not be null or empty. /// Thrown when is less than 1 or greater than 6. + /// Thrown when is null. + /// Thrown when is empty. /// Thrown if this writer has been disposed. public void WriteHeading(int level, string text) { @@ -52,6 +54,13 @@ public void WriteHeading(int level, string text) ArgumentOutOfRangeException.ThrowIfNegativeOrZero(level); ArgumentOutOfRangeException.ThrowIfGreaterThan(level, 6); + // Reject null or empty text — a heading with no text is not valid Markdown + ArgumentNullException.ThrowIfNull(text); + if (string.IsNullOrEmpty(text)) + { + throw new ArgumentException("Heading text must not be empty.", nameof(text)); + } + // Emit the ATX heading prefix followed by the text and a blank line to // separate the heading from the next block element var prefix = new string('#', level); @@ -67,12 +76,17 @@ public void WriteHeading(int level, string text) /// Must not be null; use an empty string for an unlabelled fence. /// /// Signature text. Must not be null. + /// Thrown when or is null. /// Thrown if this writer has been disposed. public void WriteSignature(string language, string code) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(_disposed, this); + // Guard against null parameters so callers get a clear diagnostic + ArgumentNullException.ThrowIfNull(language); + ArgumentNullException.ThrowIfNull(code); + // Emit a standard fenced code block with the language hint followed by a // blank line so that Markdown renderers correctly separate this block from // the next element @@ -86,12 +100,16 @@ public void WriteSignature(string language, string code) /// Writes a prose paragraph of documentation text. /// /// Paragraph body. Must not be null. + /// Thrown when is null. /// Thrown if this writer has been disposed. public void WriteParagraph(string text) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(_disposed, this); + // Guard against null parameter + ArgumentNullException.ThrowIfNull(text); + // Emit the text followed by a blank line to close the Markdown paragraph _writer.WriteLine(text); _writer.WriteLine(); @@ -105,12 +123,67 @@ public void WriteParagraph(string text) /// Data rows, each containing the same number of cells as /// . Must not be null; may be empty. /// + /// Thrown when or is null. + /// + /// Thrown when is empty, when any element in + /// is null, when any row in + /// is null, when any row has a different number of + /// cells than , or when any cell within a row is null. + /// /// Thrown if this writer has been disposed. public void WriteTable(string[] headers, IEnumerable rows) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(_disposed, this); + // Guard against null parameters + ArgumentNullException.ThrowIfNull(headers); + ArgumentNullException.ThrowIfNull(rows); + + // Reject an empty headers array — a headerless table is not valid Markdown + if (headers.Length == 0) + { + throw new ArgumentException("Headers array must not be empty.", nameof(headers)); + } + + // Reject any header element that is null — a null header cannot produce a valid column label + for (var i = 0; i < headers.Length; i++) + { + if (headers[i] == null) + { + throw new ArgumentException($"Header at index {i} must not be null.", nameof(headers)); + } + } + + // Materialize rows once so the column count of each row can be validated before + // any output is written; this prevents partially-written malformed tables + var rowList = rows.ToList(); + + // Reject any row that is null or whose column count does not match the header count + for (var i = 0; i < rowList.Count; i++) + { + if (rowList[i] == null) + { + throw new ArgumentException("Table rows must not contain null entries.", nameof(rows)); + } + + if (rowList[i].Length != headers.Length) + { + throw new ArgumentException( + $"Row {i} has {rowList[i].Length} cell(s) but {headers.Length} were expected to match the header count.", + nameof(rows)); + } + + // Reject any cell within the row that is null — a null cell cannot be rendered as Markdown + for (var j = 0; j < rowList[i].Length; j++) + { + if (rowList[i][j] == null) + { + throw new ArgumentException($"Row {i}, cell {j} must not be null.", nameof(rows)); + } + } + } + // Emit the header row with pipe delimiters; escape any literal pipe characters // in cell values so they do not break the table structure in Markdown renderers _writer.WriteLine("| " + string.Join(" | ", headers.Select(h => h.Replace("|", @"\|", StringComparison.Ordinal))) + " |"); @@ -121,7 +194,7 @@ public void WriteTable(string[] headers, IEnumerable rows) _writer.WriteLine("| " + string.Join(" | ", headers.Select(_ => "---")) + " |"); // Emit each data row, escaping pipe characters in every cell value - foreach (var row in rows) + foreach (var row in rowList) { _writer.WriteLine("| " + string.Join(" | ", row.Select(cell => cell.Replace("|", @"\|", StringComparison.Ordinal))) + " |"); } @@ -138,12 +211,17 @@ public void WriteTable(string[] headers, IEnumerable rows) /// use an empty string for an unlabelled fence. /// /// Example code. Must not be null. + /// Thrown when or is null. /// Thrown if this writer has been disposed. public void WriteCodeBlock(string language, string code) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(_disposed, this); + // Guard against null parameters so callers get a clear diagnostic + ArgumentNullException.ThrowIfNull(language); + ArgumentNullException.ThrowIfNull(code); + // Both WriteSignature and WriteCodeBlock produce identical fenced code block output; // WriteCodeBlock delegates here to avoid duplication. The methods are distinct at the // API level only; post-processors may differentiate them in future. @@ -158,12 +236,24 @@ public void WriteCodeBlock(string language, string code) /// Relative path to the target file, written verbatim into the link href. /// Must not be null. /// + /// Thrown when or is null. + /// Thrown when is empty. /// Thrown if this writer has been disposed. public void WriteLink(string text, string relativePath) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(_disposed, this); + // Guard against null parameters so callers get a clear diagnostic + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(relativePath); + + // Reject empty link text — a link with no visible label is not valid Markdown + if (string.IsNullOrEmpty(text)) + { + throw new ArgumentException("Link text must not be empty.", nameof(text)); + } + // Emit a standard inline Markdown link followed by a blank line _writer.WriteLine($"[{text}]({relativePath})"); _writer.WriteLine(); diff --git a/src/ApiMark.Core/GlobFileCollector.cs b/src/ApiMark.Core/GlobFileCollector.cs index b1ad48c..42d65b2 100644 --- a/src/ApiMark.Core/GlobFileCollector.cs +++ b/src/ApiMark.Core/GlobFileCollector.cs @@ -63,13 +63,24 @@ public static class GlobFileCollector /// Sorted, deduplicated list of absolute file paths that match the accumulated /// inclusion patterns and are not removed by any exclusion pattern. /// + /// + /// Thrown when , , + /// or is null. Empty collections and an empty + /// working directory string are valid; null references are not. + /// public static IReadOnlyList Collect( IEnumerable patterns, IEnumerable languageExtensions, string workingDirectory) { + // Guard against null arguments — empty patterns and extensions are valid + // but null references indicate a programming error in the caller + ArgumentNullException.ThrowIfNull(patterns); + ArgumentNullException.ThrowIfNull(languageExtensions); + ArgumentNullException.ThrowIfNull(workingDirectory); + var extensions = new HashSet(languageExtensions, StringComparer.OrdinalIgnoreCase); - var collected = new HashSet(StringComparer.OrdinalIgnoreCase); + var collected = new HashSet(StringComparer.Ordinal); foreach (var pattern in patterns) { diff --git a/src/ApiMark.Core/IApiGenerator.cs b/src/ApiMark.Core/IApiGenerator.cs index 7a1b34e..4c15202 100644 --- a/src/ApiMark.Core/IApiGenerator.cs +++ b/src/ApiMark.Core/IApiGenerator.cs @@ -20,7 +20,7 @@ public interface IApiGenerator { /// /// Parses the configured software component and returns an emitter ready to - /// produce Markdown documentation in the requested format. + /// emit documentation in any supported output format. /// /// /// Output channel used to emit informational and error messages during parsing. diff --git a/src/ApiMark.Core/IMarkdownWriterFactory.cs b/src/ApiMark.Core/IMarkdownWriterFactory.cs index bcecd51..cb6d714 100644 --- a/src/ApiMark.Core/IMarkdownWriterFactory.cs +++ b/src/ApiMark.Core/IMarkdownWriterFactory.cs @@ -6,9 +6,10 @@ namespace ApiMark.Core; /// /// Decouples language generators from the file system. The file-system /// implementation (FileMarkdownWriterFactory) writes to disk; test doubles -/// capture writes in memory. Callers inject the factory into -/// IApiGenerator.Generate so that the same generator code works in both -/// production and tests. +/// capture writes in memory. Callers inject the factory into language +/// generators, which are then invoked through IApiGenerator.Parse +/// and IApiEmitter.Emit, so that the same generator code works in +/// both production and tests. /// public interface IMarkdownWriterFactory { @@ -16,8 +17,9 @@ public interface IMarkdownWriterFactory /// Creates a Markdown writer for a single output file. /// /// - /// Subfolder path relative to the output root. Pass an empty string to - /// create a root-level file. Path separators should use '/' (forward slash). + /// Subfolder path relative to the output root. Pass an empty string or a + /// whitespace-only string for to create a + /// root-level file. Path separators should use '/' (forward slash). /// /// /// File name without extension. Must not be null, empty, or whitespace. diff --git a/src/ApiMark.Core/PathHelpers.cs b/src/ApiMark.Core/PathHelpers.cs index 370b0a3..618103a 100644 --- a/src/ApiMark.Core/PathHelpers.cs +++ b/src/ApiMark.Core/PathHelpers.cs @@ -13,7 +13,7 @@ namespace ApiMark.Core; internal static class PathHelpers { /// - /// Safely combines a base path with one or more path segments, ensuring the result + /// Safely combines a base path with zero or more path segments, ensuring the result /// remains within the base directory. /// /// @@ -27,6 +27,7 @@ internal static class PathHelpers /// Individual segments may contain .. or be rooted provided the combined result /// does not escape the base — for example segments ["baa", ".."] on base /// C:\foo resolve back to C:\foo and are accepted. + /// When no segments are supplied, returns unchanged. /// This method is stateless and thread-safe. /// /// @@ -34,13 +35,14 @@ internal static class PathHelpers /// not exist on disk because only string and normalized-path operations are performed. /// /// - /// One or more path segments to append in order. Must not be null, and each individual + /// Zero or more path segments to append in order. Must not be null, and each individual /// segment must not be null. /// /// /// The result of joining with all segments in /// . The returned path always resolves within - /// . + /// . Returns unchanged when + /// no segments are supplied. /// /// /// Thrown when , , or any diff --git a/test/ApiMark.Core.TestHelpers/InMemoryContext.cs b/test/ApiMark.Core.TestHelpers/InMemoryContext.cs index 20de862..d374b08 100644 --- a/test/ApiMark.Core.TestHelpers/InMemoryContext.cs +++ b/test/ApiMark.Core.TestHelpers/InMemoryContext.cs @@ -38,9 +38,10 @@ public sealed class InMemoryContext : IContext /// Captures an informational message by appending it to . /// /// The message to capture. Must not be null. + /// Thrown when is null. public void WriteLine(string message) { - // Append the message to the in-memory list for later assertion + ArgumentNullException.ThrowIfNull(message); _lines.Add(message); } @@ -48,9 +49,10 @@ public void WriteLine(string message) /// Captures an error or warning message by appending it to . /// /// The error or warning message to capture. Must not be null. + /// Thrown when is null. public void WriteError(string message) { - // Append the error message to the in-memory list for later assertion + ArgumentNullException.ThrowIfNull(message); _errors.Add(message); } } diff --git a/test/ApiMark.Core.TestHelpers/InMemoryMarkdownWriter.cs b/test/ApiMark.Core.TestHelpers/InMemoryMarkdownWriter.cs index f595bbc..a6c9cec 100644 --- a/test/ApiMark.Core.TestHelpers/InMemoryMarkdownWriter.cs +++ b/test/ApiMark.Core.TestHelpers/InMemoryMarkdownWriter.cs @@ -18,7 +18,7 @@ public abstract record MarkdownOperation; /// /// Records a call to . /// -/// The heading depth that was passed (1–4). +/// The heading depth that was passed (1–6). /// The heading text that was passed. public sealed record HeadingOperation(int Level, string Text) : MarkdownOperation; @@ -91,13 +91,28 @@ public sealed class InMemoryMarkdownWriter : IMarkdownWriter /// /// Records a with the supplied arguments. /// - /// Heading depth 1–4. - /// Heading text. + /// Heading depth 1–6. + /// Heading text. Must not be null or empty. + /// Thrown when is less than 1 or greater than 6. + /// Thrown when is null. + /// Thrown when is empty. /// Thrown if this writer has been disposed. public void WriteHeading(int level, string text) { // Guard against use-after-dispose to match the IMarkdownWriter contract ObjectDisposedException.ThrowIf(IsDisposed, this); + + // Enforce the 1–6 heading level range to match the FileMarkdownWriter contract + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(level); + ArgumentOutOfRangeException.ThrowIfGreaterThan(level, 6); + + // Reject null or empty text to match the FileMarkdownWriter contract + ArgumentNullException.ThrowIfNull(text); + if (string.IsNullOrEmpty(text)) + { + throw new ArgumentException("Heading text must not be empty.", nameof(text)); + } + _operations.Add(new HeadingOperation(level, text)); } @@ -106,11 +121,17 @@ public void WriteHeading(int level, string text) /// /// Language identifier. /// Signature code text. + /// Thrown when or is null. /// Thrown if this writer has been disposed. public void WriteSignature(string language, string code) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(IsDisposed, this); + + // Enforce null contract to match the production IMarkdownWriter contract + ArgumentNullException.ThrowIfNull(language); + ArgumentNullException.ThrowIfNull(code); + _operations.Add(new SignatureOperation(language, code)); } @@ -118,11 +139,16 @@ public void WriteSignature(string language, string code) /// Records a with the supplied text. /// /// Paragraph body text. + /// Thrown when is null. /// Thrown if this writer has been disposed. public void WriteParagraph(string text) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(IsDisposed, this); + + // Enforce null contract + ArgumentNullException.ThrowIfNull(text); + _operations.Add(new ParagraphOperation(text)); } @@ -130,23 +156,78 @@ public void WriteParagraph(string text) /// Records a capturing the headers and a /// snapshot of the rows. /// - /// Column header labels. + /// Column header labels. Must not be null or empty. /// - /// Data rows. The sequence is deep-copied (both the outer list and each inner + /// Data rows, each containing the same number of cells as + /// . The sequence is deep-copied (both the outer list and each inner /// row array are snapshotted) so that post-call mutation of the source arrays /// does not affect the recorded operation. /// + /// Thrown when or is null. + /// + /// Thrown when is empty, when any element in + /// is null, when any row in + /// is null, when any row has a different number of + /// cells than , or when any cell within a row is null. + /// /// Thrown if this writer has been disposed. public void WriteTable(string[] headers, IEnumerable rows) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(IsDisposed, this); + // Enforce null contract to match the production IMarkdownWriter contract + ArgumentNullException.ThrowIfNull(headers); + ArgumentNullException.ThrowIfNull(rows); + + // Reject an empty headers array — same contract as FileMarkdownWriter + if (headers.Length == 0) + { + throw new ArgumentException("Headers array must not be empty.", nameof(headers)); + } + + // Reject any header element that is null — same contract as FileMarkdownWriter + for (var i = 0; i < headers.Length; i++) + { + if (headers[i] == null) + { + throw new ArgumentException($"Header at index {i} must not be null.", nameof(headers)); + } + } + + // Materialize rows to validate column counts before recording + var rowList = rows.ToList(); + + // Reject any row that is null or whose column count does not match the header count + for (var i = 0; i < rowList.Count; i++) + { + if (rowList[i] == null) + { + throw new ArgumentException("Table rows must not contain null entries.", nameof(rows)); + } + + if (rowList[i].Length != headers.Length) + { + throw new ArgumentException( + $"Row {i} has {rowList[i].Length} cell(s) but {headers.Length} were expected to match the header count.", + nameof(rows)); + } + + // Reject any cell within the row that is null — same contract as FileMarkdownWriter + for (var j = 0; j < rowList[i].Length; j++) + { + if (rowList[i][j] == null) + { + throw new ArgumentException($"Row {i}, cell {j} must not be null.", nameof(rows)); + } + } + } + // Snapshot both collections so that the recorded operation is immune to // post-call mutation: headers are copied defensively; rows are deep-copied // (both the outer list and each inner row array) so that callers cannot // mutate previously recorded row data by modifying the arrays after the call - _operations.Add(new TableOperation(headers.ToArray(), rows.Select(r => (string[])r.Clone()).ToList())); + _operations.Add(new TableOperation(headers.ToArray(), rowList.Select(r => (string[])r.Clone()).ToList())); } /// @@ -154,24 +235,43 @@ public void WriteTable(string[] headers, IEnumerable rows) /// /// Language identifier. /// Example code text. + /// Thrown when or is null. /// Thrown if this writer has been disposed. public void WriteCodeBlock(string language, string code) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(IsDisposed, this); + + // Enforce null contract + ArgumentNullException.ThrowIfNull(language); + ArgumentNullException.ThrowIfNull(code); + _operations.Add(new CodeBlockOperation(language, code)); } /// /// Records a with the supplied arguments. /// - /// Visible link label. + /// Visible link label. Must not be null or empty. /// Relative path target. + /// Thrown when or is null. + /// Thrown when is empty. /// Thrown if this writer has been disposed. public void WriteLink(string text, string relativePath) { // Guard against use-after-dispose ObjectDisposedException.ThrowIf(IsDisposed, this); + + // Enforce null contract + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(relativePath); + + // Reject empty link text to match the FileMarkdownWriter contract + if (string.IsNullOrEmpty(text)) + { + throw new ArgumentException("Link text must not be empty.", nameof(text)); + } + _operations.Add(new LinkOperation(text, relativePath)); } diff --git a/test/ApiMark.Core.Tests/EmitConfigTests.cs b/test/ApiMark.Core.Tests/EmitConfigTests.cs new file mode 100644 index 0000000..c6ed0e9 --- /dev/null +++ b/test/ApiMark.Core.Tests/EmitConfigTests.cs @@ -0,0 +1,77 @@ +using ApiMark.Core; +using Xunit; + +namespace ApiMark.Core.Tests; + +/// +/// Verifies the value object, confirming that default +/// property values are correct and that validation rejects out-of-range inputs. +/// +public sealed class EmitConfigTests +{ + /// + /// Verifies that setting to zero throws + /// because zero is below the minimum + /// valid depth of 1. + /// + [Fact] + public void EmitConfig_HeadingDepth_BelowMinimum_ThrowsArgumentOutOfRangeException() + { + // Arrange / Act / Assert: depth 0 is below the valid range and must be rejected + Assert.Throws(() => new EmitConfig { HeadingDepth = 0 }); + } + + /// + /// Verifies that setting to 4 throws + /// because 4 is above the maximum valid + /// depth of 3 (which would produce an H7 member heading, unsupported by Markdown). + /// + [Fact] + public void EmitConfig_HeadingDepth_AboveMaximum_ThrowsArgumentOutOfRangeException() + { + // Arrange / Act / Assert: depth 4 is above the valid range and must be rejected + Assert.Throws(() => new EmitConfig { HeadingDepth = 4 }); + } + + /// + /// Verifies that setting to 2 succeeds + /// and the property reflects the supplied value. + /// + [Fact] + public void EmitConfig_HeadingDepth_ValidNonDefault_SetsCorrectly() + { + // Arrange / Act: construct EmitConfig with a non-default HeadingDepth + var config = new EmitConfig { HeadingDepth = 2 }; + + // Assert: the property must reflect the supplied value + Assert.Equal(2, config.HeadingDepth); + } + + /// + /// Verifies that defaults to + /// when no explicit value is supplied. + /// + [Fact] + public void EmitConfig_DefaultFormat_IsGradualDisclosure() + { + // Arrange / Act: construct EmitConfig with no explicit Format + var config = new EmitConfig(); + + // Assert: the default format must be GradualDisclosure + Assert.Equal(OutputFormat.GradualDisclosure, config.Format); + } + + /// + /// Verifies that defaults to 1 + /// when no explicit value is supplied. + /// + [Fact] + public void EmitConfig_DefaultHeadingDepth_IsOne() + { + // Arrange / Act: construct EmitConfig with no explicit HeadingDepth + var config = new EmitConfig(); + + // Assert: the default heading depth must be 1 + Assert.Equal(1, config.HeadingDepth); + } +} diff --git a/test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs b/test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs index a717dfa..dbd68e3 100644 --- a/test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs +++ b/test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs @@ -68,6 +68,17 @@ public void FileMarkdownWriterFactory_Constructor_WhitespaceDirectory_ThrowsArgu Assert.Throws(() => new FileMarkdownWriterFactory(" ")); } + /// + /// Verifies that constructing with an + /// empty string output directory throws . + /// + [Fact] + public void FileMarkdownWriterFactory_Constructor_EmptyDirectory_ThrowsArgumentException() + { + // Arrange / Act / Assert: empty strings must be rejected at construction time + Assert.Throws(() => new FileMarkdownWriterFactory("")); + } + /// /// Verifies that calling /// with a null file name throws . @@ -82,6 +93,34 @@ public void FileMarkdownWriterFactory_CreateMarkdown_NullName_ThrowsArgumentExce Assert.Throws(() => factory.CreateMarkdown("", null!)); } + /// + /// Verifies that calling + /// with an empty string file name throws . + /// + [Fact] + public void FileMarkdownWriterFactory_CreateMarkdown_EmptyName_ThrowsArgumentException() + { + // Arrange: create a factory pointing at the temp directory + var factory = new FileMarkdownWriterFactory(_tempDirectory); + + // Act / Assert: empty name must be rejected before any I/O is attempted + Assert.Throws(() => factory.CreateMarkdown("", "")); + } + + /// + /// Verifies that calling + /// with a whitespace-only file name throws . + /// + [Fact] + public void FileMarkdownWriterFactory_CreateMarkdown_WhitespaceName_ThrowsArgumentException() + { + // Arrange: create a factory pointing at the temp directory + var factory = new FileMarkdownWriterFactory(_tempDirectory); + + // Act / Assert: whitespace-only name must be rejected before any I/O is attempted + Assert.Throws(() => factory.CreateMarkdown("", " ")); + } + /// /// Verifies that passing an empty subfolder writes the file directly under /// the output root and that the file exists after the writer is disposed. @@ -151,4 +190,26 @@ public void FileMarkdownWriterFactory_CreateMarkdown_NonExistentDirectory_Create Assert.True(Directory.Exists(nonExistentDir), "Factory must create the output directory if it does not exist."); Assert.True(File.Exists(Path.Join(nonExistentDir, "index.md"))); } + + /// + /// Verifies that passing a whitespace-only subfolder writes the file directly + /// under the output root — treating whitespace as equivalent to an empty subfolder. + /// + [Fact] + public void FileMarkdownWriterFactory_CreateMarkdown_WhitespaceSubFolder_CreatesRootLevelFile() + { + // Arrange: create a factory pointing at the temp directory + var factory = new FileMarkdownWriterFactory(_tempDirectory); + + // Act: create a writer with a whitespace-only subFolder + using (var writer = factory.CreateMarkdown(" ", "ws-root")) + { + writer.WriteHeading(1, "Whitespace SubFolder Test"); + } + + // Assert: the file must be written directly at the root — whitespace subFolder + // is treated the same as empty string per the design contract + var expectedPath = Path.Join(_tempDirectory, "ws-root.md"); + Assert.True(File.Exists(expectedPath), $"Expected file '{expectedPath}' at root level when subFolder is whitespace."); + } } diff --git a/test/ApiMark.Core.Tests/FileMarkdownWriterTests.cs b/test/ApiMark.Core.Tests/FileMarkdownWriterTests.cs index e2b8ad0..e1cc056 100644 --- a/test/ApiMark.Core.Tests/FileMarkdownWriterTests.cs +++ b/test/ApiMark.Core.Tests/FileMarkdownWriterTests.cs @@ -75,8 +75,8 @@ public void FileMarkdownWriter_WriteHeading_Level1_WritesCorrectMarkdown() // Arrange / Act: write a level-1 heading and read the result var content = WriteAndReadFile("heading1", w => w.WriteHeading(1, "My Heading")); - // Assert: the content must contain the ATX level-1 heading syntax - Assert.Contains("# My Heading", content); + // Assert: the content must be exactly the ATX level-1 heading followed by a blank line + Assert.Equal("# My Heading" + Environment.NewLine + Environment.NewLine, content); } /// @@ -207,4 +207,380 @@ public void FileMarkdownWriter_Dispose_AfterWrite_FlushesAndClosesFile() // Assert: no exception means the file handle was released successfully Assert.Null(exception); } + + /// + /// Verifies that throws + /// when level is zero. + /// + [Fact] + public void FileMarkdownWriter_WriteHeading_ZeroLevel_ThrowsArgumentOutOfRangeException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "heading-zero"); + + // Act / Assert: level 0 is below the minimum of 1 + Assert.Throws(() => writer.WriteHeading(0, "Title")); + } + + /// + /// Verifies that throws + /// when level is greater than 6. + /// + [Fact] + public void FileMarkdownWriter_WriteHeading_SevenLevel_ThrowsArgumentOutOfRangeException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "heading-seven"); + + // Act / Assert: level 7 exceeds the maximum of 6 defined by CommonMark + Assert.Throws(() => writer.WriteHeading(7, "Title")); + } + + /// + /// Verifies that calling after + /// throws . + /// + [Fact] + public void FileMarkdownWriter_WriteHeading_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange: create and dispose a writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + var writer = factory.CreateMarkdown("", "disposed-heading"); + writer.Dispose(); + + // Act / Assert: calling a write method after disposal must throw + Assert.Throws(() => writer.WriteHeading(1, "Title")); + } + + /// + /// Verifies that calling after + /// throws . + /// + [Fact] + public void FileMarkdownWriter_WriteSignature_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange: create and dispose a writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + var writer = factory.CreateMarkdown("", "disposed-signature"); + writer.Dispose(); + + // Act / Assert: calling WriteSignature after disposal must throw + Assert.Throws(() => writer.WriteSignature("csharp", "public void Foo();")); + } + + /// + /// Verifies that calling after + /// throws . + /// + [Fact] + public void FileMarkdownWriter_WriteParagraph_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange: create and dispose a writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + var writer = factory.CreateMarkdown("", "disposed-paragraph"); + writer.Dispose(); + + // Act / Assert: calling WriteParagraph after disposal must throw + Assert.Throws(() => writer.WriteParagraph("Some text.")); + } + + /// + /// Verifies that calling after + /// throws . + /// + [Fact] + public void FileMarkdownWriter_WriteTable_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange: create and dispose a writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + var writer = factory.CreateMarkdown("", "disposed-table"); + writer.Dispose(); + string[] headers = ["Name", "Type"]; + string[][] rows = [["value", "int"]]; + + // Act / Assert: calling WriteTable after disposal must throw + Assert.Throws(() => writer.WriteTable(headers, rows)); + } + + /// + /// Verifies that calling after + /// throws . + /// + [Fact] + public void FileMarkdownWriter_WriteCodeBlock_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange: create and dispose a writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + var writer = factory.CreateMarkdown("", "disposed-codeblock"); + writer.Dispose(); + + // Act / Assert: calling WriteCodeBlock after disposal must throw + Assert.Throws(() => writer.WriteCodeBlock("csharp", "var x = 1;")); + } + + /// + /// Verifies that calling after + /// throws . + /// + [Fact] + public void FileMarkdownWriter_WriteLink_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange: create and dispose a writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + var writer = factory.CreateMarkdown("", "disposed-link"); + writer.Dispose(); + + // Act / Assert: calling WriteLink after disposal must throw + Assert.Throws(() => writer.WriteLink("Back", "../api.md")); + } + + /// + /// Verifies that throws + /// when the language parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteSignature_NullLanguage_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "sig-null-lang"); + + // Act / Assert: null language must be rejected immediately + Assert.Throws(() => writer.WriteSignature(null!, "code")); + } + + /// + /// Verifies that throws + /// when the code parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteSignature_NullCode_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "sig-null-code"); + + // Act / Assert: null code must be rejected immediately + Assert.Throws(() => writer.WriteSignature("csharp", null!)); + } + + /// + /// Verifies that throws + /// when the text parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteParagraph_NullText_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "para-null-text"); + + // Act / Assert: null text must be rejected immediately + Assert.Throws(() => writer.WriteParagraph(null!)); + } + + /// + /// Verifies that throws + /// when the headers parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteTable_NullHeaders_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "table-null-headers"); + + // Act / Assert: null headers must be rejected immediately + Assert.Throws(() => writer.WriteTable(null!, [])); + } + + /// + /// Verifies that throws + /// when the rows parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteTable_NullRows_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "table-null-rows"); + + // Act / Assert: null rows must be rejected immediately + Assert.Throws(() => writer.WriteTable([], null!)); + } + + /// + /// Verifies that throws + /// when the language parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteCodeBlock_NullLanguage_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "code-null-lang"); + + // Act / Assert: null language must be rejected immediately + Assert.Throws(() => writer.WriteCodeBlock(null!, "code")); + } + + /// + /// Verifies that throws + /// when the code parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteCodeBlock_NullCode_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "code-null-code"); + + // Act / Assert: null code must be rejected immediately + Assert.Throws(() => writer.WriteCodeBlock("csharp", null!)); + } + + /// + /// Verifies that throws + /// when the text parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteLink_NullText_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "link-null-text"); + + // Act / Assert: null text must be rejected immediately + Assert.Throws(() => writer.WriteLink(null!, "api.md")); + } + + /// + /// Verifies that throws + /// when the relativePath parameter is null. + /// + [Fact] + public void FileMarkdownWriter_WriteLink_NullRelativePath_ThrowsArgumentNullException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "link-null-path"); + + // Act / Assert: null relativePath must be rejected immediately + Assert.Throws(() => writer.WriteLink("Back", null!)); + } + + /// + /// Verifies that throws + /// when the headers array is empty. + /// + [Fact] + public void FileMarkdownWriter_WriteTable_EmptyHeaders_ThrowsArgumentException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "table-empty-headers"); + + // Act / Assert: empty headers must be rejected — a headerless table is not valid Markdown + Assert.Throws(() => writer.WriteTable([], [])); + } + + /// + /// Verifies that throws + /// when a row contains a different number of + /// cells than the headers array. + /// + [Fact] + public void FileMarkdownWriter_WriteTable_MismatchedRowLength_ThrowsArgumentException() + { + // Arrange: create a factory and writer; headers expect 2 columns, row has 3 + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "table-mismatched-row"); + string[] headers = ["Name", "Type"]; + string[][] rows = [["value", "int", "extra-cell"]]; + + // Act / Assert: a row with a different column count than headers must be rejected + Assert.Throws(() => writer.WriteTable(headers, rows)); + } + + /// + /// Verifies that throws + /// when the text parameter is empty. + /// + [Fact] + public void FileMarkdownWriter_WriteHeading_EmptyText_ThrowsArgumentException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "heading-empty-text"); + + // Act / Assert: empty heading text is not valid Markdown and must be rejected + Assert.Throws(() => writer.WriteHeading(1, "")); + } + + /// + /// Verifies that throws + /// when the text parameter is empty. + /// + [Fact] + public void FileMarkdownWriter_WriteLink_EmptyText_ThrowsArgumentException() + { + // Arrange: create a factory and writer + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "link-empty-text"); + + // Act / Assert: empty link text produces a Markdown link with no visible label and must be rejected + Assert.Throws(() => writer.WriteLink("", "api.md")); + } + + /// + /// Verifies that throws + /// when the rows sequence contains a null element. + /// + [Fact] + public void FileMarkdownWriter_WriteTable_NullRowElement_ThrowsArgumentException() + { + // Arrange: create a factory and writer; second element in rows is null + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "table-null-row-element"); + string[] headers = ["Name", "Type"]; + string[][] rows = [["a", "b"], null!, ["c", "d"]]; + + // Act / Assert: a null row element in the rows sequence must be rejected + Assert.Throws(() => writer.WriteTable(headers, rows)); + } + + /// + /// Verifies that throws + /// when the headers array contains a null element. + /// + [Fact] + public void FileMarkdownWriter_WriteTable_NullHeaderElement_ThrowsArgumentException() + { + // Arrange: create a factory and writer; second header is null + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "table-null-header-element"); + string[] headers = ["Name", null!, "Description"]; + + // Act / Assert: a null header element must be rejected before any output is written + Assert.Throws(() => writer.WriteTable(headers, [])); + } + + /// + /// Verifies that throws + /// when a row in the rows sequence contains a null cell. + /// + [Fact] + public void FileMarkdownWriter_WriteTable_NullCellElement_ThrowsArgumentException() + { + // Arrange: create a factory and writer; a cell within a row is null + var factory = new FileMarkdownWriterFactory(_tempDirectory); + using var writer = factory.CreateMarkdown("", "table-null-cell-element"); + string[] headers = ["Name", "Type"]; + string[][] rows = [["value", null!]]; + + // Act / Assert: a null cell within a row must be rejected before any output is written + Assert.Throws(() => writer.WriteTable(headers, rows)); + } } diff --git a/test/ApiMark.Core.Tests/GlobFileCollectorTests.cs b/test/ApiMark.Core.Tests/GlobFileCollectorTests.cs index 1029f7c..48f1880 100644 --- a/test/ApiMark.Core.Tests/GlobFileCollectorTests.cs +++ b/test/ApiMark.Core.Tests/GlobFileCollectorTests.cs @@ -461,4 +461,44 @@ public void GlobFileCollector_Collect_LiteralAbsoluteExclusionPath_WrongExtensio Directory.Delete(tempDir, recursive: true); } } + + // ========================================================================= + // Null-argument guard tests + // ========================================================================= + + /// + /// Verifies that passing null for patterns throws + /// immediately. + /// + [Fact] + public void GlobFileCollector_Collect_NullPatterns_ThrowsArgumentNullException() + { + // Arrange / Act / Assert: null patterns must be rejected at the API boundary + Assert.Throws(() => + GlobFileCollector.Collect(null!, VhdlExtensions, Path.GetTempPath())); + } + + /// + /// Verifies that passing null for languageExtensions throws + /// immediately. + /// + [Fact] + public void GlobFileCollector_Collect_NullLanguageExtensions_ThrowsArgumentNullException() + { + // Arrange / Act / Assert: null languageExtensions must be rejected at the API boundary + Assert.Throws(() => + GlobFileCollector.Collect([], null!, Path.GetTempPath())); + } + + /// + /// Verifies that passing null for workingDirectory throws + /// immediately. + /// + [Fact] + public void GlobFileCollector_Collect_NullWorkingDirectory_ThrowsArgumentNullException() + { + // Arrange / Act / Assert: null workingDirectory must be rejected at the API boundary + Assert.Throws(() => + GlobFileCollector.Collect([], VhdlExtensions, null!)); + } } diff --git a/test/ApiMark.Core.Tests/IApiEmitterTests.cs b/test/ApiMark.Core.Tests/IApiEmitterTests.cs index e145e9f..36ba905 100644 --- a/test/ApiMark.Core.Tests/IApiEmitterTests.cs +++ b/test/ApiMark.Core.Tests/IApiEmitterTests.cs @@ -43,7 +43,7 @@ public void IApiEmitter_Emit_WithGradualDisclosure_ProducesMultipleFiles() [Fact] public void IApiEmitter_Emit_WithSingleFile_ProducesSingleApiMd() { - // Arrange: a stub emitter that honours SingleFile by writing only api.md + // Arrange: a stub emitter that honors SingleFile by writing only api.md var factory = new InMemoryMarkdownWriterFactory(); IApiEmitter emitter = new SingleFileEmitter(); var config = new EmitConfig { Format = OutputFormat.SingleFile }; @@ -66,6 +66,9 @@ private sealed class MultiFileEmitter : IApiEmitter /// Creates the root api.md and one additional namespace page so /// the test can assert that more than one file was produced. /// + /// The writer factory used to create output files. + /// The emit configuration; not inspected by this stub. + /// The diagnostic context; not used by this stub. public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) { // Root entrypoint @@ -88,6 +91,9 @@ private sealed class SingleFileEmitter : IApiEmitter /// Creates only the root api.md and writes all content into it — /// single-file contract. /// + /// The writer factory used to create output files. + /// The emit configuration providing the heading depth to use. + /// The diagnostic context; not used by this stub. public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) { // Only api.md is created — single-file format consolidates everything here @@ -95,4 +101,141 @@ public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext con root.WriteHeading(config.HeadingDepth, "API Reference"); } } + + // ========================================================================= + // Format-selection tests (format decision belongs at the IApiEmitter level) + // ========================================================================= + + /// + /// Verifies that a format-aware implementation produces + /// multiple output files when is + /// . + /// + [Fact] + public void IApiEmitter_Emit_GradualDisclosure_ProducesMultipleFiles() + { + // Arrange: a format-aware stub and a GradualDisclosure config + var factory = new InMemoryMarkdownWriterFactory(); + IApiEmitter emitter = new FormatAwareStubEmitter(); + var config = new EmitConfig { Format = OutputFormat.GradualDisclosure }; + + // Act: invoke Emit — the emitter must read config.Format and produce multiple files + emitter.Emit(factory, config, new InMemoryContext()); + + // Assert: more than one file must be created when GradualDisclosure is selected + Assert.True(factory.Writers.Count > 1, "GradualDisclosure format must produce more than one file."); + Assert.True(factory.HasWriter("", "api"), "GradualDisclosure format must include the root api.md entrypoint."); + } + + /// + /// Verifies that a format-aware implementation produces + /// exactly one output file named api.md when + /// is . + /// + [Fact] + public void IApiEmitter_Emit_SingleFile_ProducesSingleApiMd() + { + // Arrange: a format-aware stub and a SingleFile config + var factory = new InMemoryMarkdownWriterFactory(); + IApiEmitter emitter = new FormatAwareStubEmitter(); + var config = new EmitConfig { Format = OutputFormat.SingleFile }; + + // Act: invoke Emit — the emitter must read config.Format and write only api.md + emitter.Emit(factory, config, new InMemoryContext()); + + // Assert: exactly one file must be created and it must be api.md at the root + Assert.Single(factory.Writers); + Assert.True(factory.HasWriter("", "api"), "SingleFile format must write only factory.CreateMarkdown(\"\", \"api\")."); + } + + /// + /// Format-aware stub emitter that reads to decide + /// whether to produce multiple files (GradualDisclosure) or a single api.md + /// (SingleFile). Used to verify that format-selection is correctly honored at + /// the level. + /// + private sealed class FormatAwareStubEmitter : IApiEmitter + { + /// + /// Validates preconditions then produces either multi-file or single-file + /// output depending on . + /// + /// The writer factory used to create output files. + /// The emit configuration whose Format determines the output shape. + /// The diagnostic context; not used by this stub. + public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(context); + + if (config.Format == OutputFormat.GradualDisclosure) + { + // Multi-file: root api.md plus one additional page + using var root = factory.CreateMarkdown("", "api"); + root.WriteHeading(1, "API Reference"); + + using var page = factory.CreateMarkdown("MyNamespace", "MyNamespace"); + page.WriteHeading(2, "MyNamespace"); + } + else + { + // Single-file: all content goes into api.md only + using var root = factory.CreateMarkdown("", "api"); + root.WriteHeading(config.HeadingDepth, "API Reference"); + } + } + } + + // ========================================================================= + // Null-precondition tests (all Emit parameters must be non-null) + // ========================================================================= + + /// + /// Verifies that throws + /// when factory is null. + /// + [Fact] + public void IApiEmitter_Emit_NullFactory_ThrowsArgumentNullException() + { + // Arrange: a format-aware stub emitter that validates its arguments + IApiEmitter emitter = new FormatAwareStubEmitter(); + var config = new EmitConfig(); + var context = new InMemoryContext(); + + // Act / Assert: null factory must be rejected with ArgumentNullException + Assert.Throws(() => emitter.Emit(null!, config, context)); + } + + /// + /// Verifies that throws + /// when config is null. + /// + [Fact] + public void IApiEmitter_Emit_NullConfig_ThrowsArgumentNullException() + { + // Arrange: a format-aware stub emitter that validates its arguments + IApiEmitter emitter = new FormatAwareStubEmitter(); + var factory = new InMemoryMarkdownWriterFactory(); + var context = new InMemoryContext(); + + // Act / Assert: null config must be rejected with ArgumentNullException + Assert.Throws(() => emitter.Emit(factory, null!, context)); + } + + /// + /// Verifies that throws + /// when context is null. + /// + [Fact] + public void IApiEmitter_Emit_NullContext_ThrowsArgumentNullException() + { + // Arrange: a format-aware stub emitter that validates its arguments + IApiEmitter emitter = new FormatAwareStubEmitter(); + var factory = new InMemoryMarkdownWriterFactory(); + var config = new EmitConfig(); + + // Act / Assert: null context must be rejected with ArgumentNullException + Assert.Throws(() => emitter.Emit(factory, config, null!)); + } } diff --git a/test/ApiMark.Core.Tests/IApiGeneratorTests.cs b/test/ApiMark.Core.Tests/IApiGeneratorTests.cs index 57daa28..d85ad1b 100644 --- a/test/ApiMark.Core.Tests/IApiGeneratorTests.cs +++ b/test/ApiMark.Core.Tests/IApiGeneratorTests.cs @@ -8,7 +8,7 @@ namespace ApiMark.Core.Tests; /// Verifies the interface contract. These tests /// confirm that the interface can be implemented, that construction-time /// configuration is accessible at generation time, and that the api.md -/// entrypoint contract is honoured. +/// entrypoint contract is honored. /// public sealed class IApiGeneratorTests { @@ -68,49 +68,6 @@ public void IApiGenerator_Emit_OutputDirectory_ContainsApiMd() Assert.True(factory.HasWriter("", "api"), "Generator must call factory.CreateMarkdown(\"\", \"api\") to produce api.md."); } - /// - /// Verifies that when is - /// , calling Emit causes the - /// factory to receive more than one CreateMarkdown call (i.e., multiple files - /// are produced rather than a single consolidated output). - /// - [Fact] - public void IApiGenerator_Emit_GradualDisclosure_ProducesMultipleFiles() - { - // Arrange: a stub generator that produces two files under GradualDisclosure - var factory = new InMemoryMarkdownWriterFactory(); - IApiGenerator generator = new MultiFileStubGenerator(); - var config = new EmitConfig { Format = OutputFormat.GradualDisclosure }; - - // Act: parse then emit with the GradualDisclosure format - generator.Parse(new InMemoryContext()).Emit(factory, config, new InMemoryContext()); - - // Assert: more than one file must be created — GradualDisclosure is multi-file - Assert.True(factory.Writers.Count > 1, "GradualDisclosure format must produce more than one file."); - Assert.True(factory.HasWriter("", "api"), "GradualDisclosure format must include the root api.md entrypoint."); - } - - /// - /// Verifies that when is - /// , calling Emit causes the factory to - /// receive exactly one CreateMarkdown call writing only api.md. - /// - [Fact] - public void IApiGenerator_Emit_SingleFile_ProducesSingleApiMd() - { - // Arrange: a stub generator that honours SingleFile by writing only api.md - var factory = new InMemoryMarkdownWriterFactory(); - IApiGenerator generator = new SingleFileStubGenerator(); - var config = new EmitConfig { Format = OutputFormat.SingleFile }; - - // Act: parse then emit with the SingleFile format - generator.Parse(new InMemoryContext()).Emit(factory, config, new InMemoryContext()); - - // Assert: exactly one file must be created and it must be api.md at the root - Assert.Single(factory.Writers); - Assert.True(factory.HasWriter("", "api"), "SingleFile format must write only factory.CreateMarkdown(\"\", \"api\")."); - } - /// /// System-level test: verifies that a complete /// implementation can be invoked through the interface contract without error. @@ -129,6 +86,21 @@ public void ApiMarkCore_GeneratorContract_SupportedLanguage_CanBeInvoked() Assert.Null(exception); } + /// + /// Verifies that throws + /// when a null context is supplied, + /// confirming the interface-level null-precondition contract. + /// + [Fact] + public void IApiGenerator_Parse_NullContext_ThrowsArgumentNullException() + { + // Arrange: a minimal stub that validates context is not null + IApiGenerator generator = new NullContextRejectingStubGenerator(); + + // Act / Assert: passing null must be rejected with ArgumentNullException + Assert.Throws(() => generator.Parse(null!)); + } + /// /// Minimal stub generator that accepts a context and returns a no-op emitter. /// Used to verify the method signature is callable. @@ -222,61 +194,22 @@ public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext con } /// - /// Stub generator whose emitter writes two files to simulate multi-file - /// GradualDisclosure output: one root api.md and one additional page. + /// Stub generator that validates the context argument is not null before + /// proceeding, documenting the expected null-precondition contract for + /// . /// - private sealed class MultiFileStubGenerator : IApiGenerator + private sealed class NullContextRejectingStubGenerator : IApiGenerator { /// - /// Returns a multi-file emitter regardless of the EmitConfig passed to Emit. + /// Validates that is not null and returns a no-op + /// emitter. Throws for a null argument. /// - public IApiEmitter Parse(IContext context) => new MultiFileEmitter(); - - /// Emitter that always creates two files. - private sealed class MultiFileEmitter : IApiEmitter - { - /// - /// Creates the root api.md and one additional namespace page so - /// the test can assert that more than one file was produced. - /// - public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) - { - // Root entrypoint - using var root = factory.CreateMarkdown("", "api"); - root.WriteHeading(1, "API Reference"); - - // One extra page — simulates a namespace or type page in GradualDisclosure - using var page = factory.CreateMarkdown("MyNamespace", "MyNamespace"); - page.WriteHeading(2, "MyNamespace"); - } - } - } - - /// - /// Stub generator whose emitter writes only api.md to simulate single-file - /// output controlled by . - /// - private sealed class SingleFileStubGenerator : IApiGenerator - { - /// - /// Returns an emitter that respects by - /// writing only api.md. - /// - public IApiEmitter Parse(IContext context) => new SingleFileEmitter(); - - /// Emitter that writes exclusively to api.md. - private sealed class SingleFileEmitter : IApiEmitter + /// The diagnostic channel; must not be null. + /// A no-op emitter. + public IApiEmitter Parse(IContext context) { - /// - /// Creates only the root api.md and writes all content into it, - /// regardless of how many concepts are present — single-file contract. - /// - public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) - { - // Only api.md is created — single-file format consolidates everything here - using var root = factory.CreateMarkdown("", "api"); - root.WriteHeading(config.HeadingDepth, "API Reference"); - } + ArgumentNullException.ThrowIfNull(context); + return new NoOpEmitter(); } } } diff --git a/test/ApiMark.Core.Tests/IContextTests.cs b/test/ApiMark.Core.Tests/IContextTests.cs index 5a788d4..e136735 100644 --- a/test/ApiMark.Core.Tests/IContextTests.cs +++ b/test/ApiMark.Core.Tests/IContextTests.cs @@ -69,4 +69,64 @@ public void InMemoryContext_WriteLineAndWriteError_RouteToSeparateChannels() Assert.Contains("error message", context.Errors); Assert.DoesNotContain("error message", context.Lines); } + + /// + /// Verifies that passing a null message to + /// throws , enforcing the null contract + /// defined on the interface. + /// + [Fact] + public void InMemoryContext_WriteLine_NullMessage_ThrowsArgumentNullException() + { + // Arrange: a fresh in-memory context + var context = new InMemoryContext(); + + // Act / Assert: null message must be rejected with ArgumentNullException + Assert.Throws(() => context.WriteLine(null!)); + } + + /// + /// Verifies that passing a null message to + /// throws , enforcing the null contract + /// defined on the interface. + /// + [Fact] + public void InMemoryContext_WriteError_NullMessage_ThrowsArgumentNullException() + { + // Arrange: a fresh in-memory context + var context = new InMemoryContext(); + + // Act / Assert: null message must be rejected with ArgumentNullException + Assert.Throws(() => context.WriteError(null!)); + } + + /// + /// Verifies that multiple and + /// calls produce messages in the exact order + /// they were written, with no reordering across channels. + /// + [Fact] + public void InMemoryContext_MultipleMessages_MaintainCallOrder() + { + // Arrange: a fresh in-memory context + var context = new InMemoryContext(); + + // Act: interleave informational and error messages in a defined sequence + context.WriteLine("line-1"); + context.WriteError("error-1"); + context.WriteLine("line-2"); + context.WriteError("error-2"); + context.WriteLine("line-3"); + + // Assert: Lines must contain the informational messages in call order + Assert.Equal(3, context.Lines.Count); + Assert.Equal("line-1", context.Lines[0]); + Assert.Equal("line-2", context.Lines[1]); + Assert.Equal("line-3", context.Lines[2]); + + // Assert: Errors must contain the error messages in call order + Assert.Equal(2, context.Errors.Count); + Assert.Equal("error-1", context.Errors[0]); + Assert.Equal("error-2", context.Errors[1]); + } } diff --git a/test/ApiMark.Core.Tests/PathHelpersTests.cs b/test/ApiMark.Core.Tests/PathHelpersTests.cs index 27a2e8c..7ea83f6 100644 --- a/test/ApiMark.Core.Tests/PathHelpersTests.cs +++ b/test/ApiMark.Core.Tests/PathHelpersTests.cs @@ -215,4 +215,21 @@ public void PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullExcep Assert.Throws(() => PathHelpers.SafePathCombine(basePath, relativePath!)); } + + /// + /// Verifies that calling with zero + /// segments returns the base path unchanged. + /// + [Fact] + public void PathHelpers_SafePathCombine_NoSegments_ReturnsBasePath() + { + // Arrange: a valid base path + var basePath = Path.Join("home", "user", "project"); + + // Act: call SafePathCombine with no additional segments + var result = PathHelpers.SafePathCombine(basePath); + + // Assert: the result must equal the base path because no segments were appended + Assert.Equal(basePath, result); + } } From f44b38a58707c874bb52fa10fe21b5b6cbf4bf9e Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 24 Jun 2026 22:32:07 -0400 Subject: [PATCH 03/62] feat(dotnet): complete DotNet subsystem review compliance (Batch 2) - Restructure DotNetGenerator, DotNetEmitter, and sub-emitters for clarity - Merge ApiVisibility.cs and DotNetGeneratorOptions.cs into DotNetGenerator.cs - Merge ExternalTypeInfo.cs into TypeLinkResolver.cs - Add IContext progress reporting to DotNetGenerator - Fix init accessor detection via RequiredModifierType (IsExternalInit modreq) - Fix array rank rendering in TypeLinkResolver and TypeNameSimplifier - Fix BuildPropertyAccessors accessibility logic - Add ThrowIfNull guards for options and context in DotNetGenerator - Add BuildInheritanceChain requirement and RejectNullContext test - Add operators page, NamespaceDoc, and nested type support - Update requirements: move emitting reqs from Generator to unit YAMLs - Update design docs: all 9 DotNet unit design docs for consistency - Update verification docs: all DotNet unit verification docs - Add copyright headers to fixture files; add new fixture classes - Add 237 passing tests across DotNet test suite Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .reviewmark.yaml | 2 + docs/design/api-mark-dot-net.md | 93 ++++-- .../api-mark-dot-net/dot-net-ast-model.md | 29 +- .../dot-net-emitter-gradual-disclosure.md | 153 +++++++++- .../dot-net-emitter-single-file.md | 32 ++- .../api-mark-dot-net/dot-net-emitter.md | 172 ++++++++--- .../api-mark-dot-net/dot-net-generator.md | 216 ++++---------- .../api-mark-dot-net/type-link-resolver.md | 36 ++- .../api-mark-dot-net/type-name-simplifier.md | 45 ++- .../design/api-mark-dot-net/xml-doc-reader.md | 59 +++- docs/design/introduction.md | 61 +--- docs/reqstream/api-mark-dot-net.yaml | 55 +++- .../api-mark-dot-net/dot-net-ast-model.yaml | 5 +- .../dot-net-emitter-gradual-disclosure.yaml | 104 ++++++- .../dot-net-emitter-single-file.yaml | 91 +++++- .../api-mark-dot-net/dot-net-emitter.yaml | 83 +++++- .../api-mark-dot-net/dot-net-generator.yaml | 165 ++++------- .../api-mark-dot-net/type-link-resolver.yaml | 75 ++++- .../type-name-simplifier.yaml | 13 +- .../api-mark-dot-net/xml-doc-reader.yaml | 19 +- docs/verification/api-mark-dot-net.md | 2 +- .../api-mark-dot-net/dot-net-ast-model.md | 24 +- .../dot-net-emitter-gradual-disclosure.md | 37 +++ .../dot-net-emitter-single-file.md | 53 ++++ .../api-mark-dot-net/dot-net-emitter.md | 75 +++++ .../api-mark-dot-net/dot-net-generator.md | 195 +++---------- .../api-mark-dot-net/type-link-resolver.md | 28 ++ .../api-mark-dot-net/type-name-simplifier.md | 40 ++- docs/verification/introduction.md | 27 +- docs/verification/ots.md | 24 ++ review-template.md | 266 ++++++++++++++++++ src/ApiMark.DotNet/ApiVisibility.cs | 14 - src/ApiMark.DotNet/DotNetAstModel.cs | 21 +- src/ApiMark.DotNet/DotNetEmitter.cs | 68 ++++- .../DotNetEmitterGradualDisclosure.cs | 61 ++-- src/ApiMark.DotNet/DotNetEmitterSingleFile.cs | 41 ++- src/ApiMark.DotNet/DotNetGenerator.cs | 57 +++- src/ApiMark.DotNet/DotNetGeneratorOptions.cs | 20 -- src/ApiMark.DotNet/ExternalTypeInfo.cs | 53 ---- src/ApiMark.DotNet/TypeLinkResolver.cs | 56 +++- src/ApiMark.DotNet/TypeNameSimplifier.cs | 34 ++- src/ApiMark.DotNet/XmlDocReader.cs | 44 ++- .../AbstractFixtureClass.cs | 20 ++ .../CaseCollisionClass.cs | 3 + .../InitPropertyClass.cs | 15 + .../IntVsIntArrayClass.cs | 3 + .../ApiMark.DotNet.Fixtures/IsExternalInit.cs | 18 ++ .../OperatorsStruct.cs | 3 + test/ApiMark.DotNet.Fixtures/OuterClass.cs | 3 + test/ApiMark.DotNet.Fixtures/SampleClass.cs | 3 + test/ApiMark.DotNet.Fixtures/SampleEnum.cs | 3 + .../StaticFixtureClass.cs | 14 + .../DotNetAstModelTests.cs | 44 ++- .../DotNetEmitterGradualDisclosureTests.cs | 116 +++++++- .../DotNetEmitterSingleFileTests.cs | 219 ++++++++++++++ .../DotNetEmitterTests.cs | 165 +++++++++++ .../DotNetGeneratorTests.cs | 75 ++++- .../TypeLinkResolverTests.cs | 167 +++++++++++ .../TypeNameSimplifierTests.cs | 100 +++++-- .../ApiMark.DotNet.Tests/XmlDocReaderTests.cs | 31 +- 60 files changed, 2881 insertions(+), 869 deletions(-) create mode 100644 review-template.md delete mode 100644 src/ApiMark.DotNet/ApiVisibility.cs delete mode 100644 src/ApiMark.DotNet/DotNetGeneratorOptions.cs delete mode 100644 src/ApiMark.DotNet/ExternalTypeInfo.cs create mode 100644 test/ApiMark.DotNet.Fixtures/AbstractFixtureClass.cs create mode 100644 test/ApiMark.DotNet.Fixtures/InitPropertyClass.cs create mode 100644 test/ApiMark.DotNet.Fixtures/IsExternalInit.cs create mode 100644 test/ApiMark.DotNet.Fixtures/StaticFixtureClass.cs diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 84c99fb..1cc7d46 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -260,6 +260,7 @@ reviews: - src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs - test/ApiMark.DotNet.Fixtures/**/*.cs - test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs + - '!test/**/obj/**' - id: ApiMark-DotNet-DotNetEmitterSingleFile title: Review that ApiMark DotNet DotNetEmitterSingleFile Implementation is Correct context: @@ -272,6 +273,7 @@ reviews: - src/ApiMark.DotNet/DotNetEmitterSingleFile.cs - test/ApiMark.DotNet.Fixtures/**/*.cs - test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs + - '!test/**/obj/**' - id: ApiMark-DotNet-XmlDocReader title: Review that ApiMark DotNet XmlDocReader Implementation is Correct context: diff --git a/docs/design/api-mark-dot-net.md b/docs/design/api-mark-dot-net.md index 697c46a..95c5474 100644 --- a/docs/design/api-mark-dot-net.md +++ b/docs/design/api-mark-dot-net.md @@ -7,18 +7,28 @@ ApiMarkDotNet provides C#/.NET language support. It reads a compiled .NET assembly and its associated XML documentation file, then produces the Markdown output -defined by the Core interfaces. The system contains eleven units: +defined by the Core interfaces. The system contains eight units: - **DotNetGenerator** — reads the assembly via Mono.Cecil, processes XML doc comments, applies visibility filtering, builds an inheritance chain map from - assembly metadata, and returns a DotNetEmitter ready for emission. + assembly metadata, and returns a DotNetEmitter ready for emission. The + inheritance chain map is also used to render direct base-type and interface + inheritance in type signatures (not only for `` resolution). + During `Parse`, DotNetGenerator recognizes `internal static class NamespaceDoc` + carrier classes, excludes them from type listings, and promotes their XML summary + to the namespace description dictionary. - **DotNetAstModel** — immutable data class holding all parsed assembly data (namespaces, types, XML docs, resolver, options) produced by DotNetGenerator.Parse. - **DotNetEmitter** — IApiEmitter dispatcher; reads EmitConfig.Format and forwards the call to DotNetEmitterGradualDisclosure or DotNetEmitterSingleFile. Also provides shared static helper methods used by both sub-emitters. - **DotNetEmitterGradualDisclosure** — writes the multi-file gradual-disclosure tree - (one file per namespace, type, and member). + (one file per namespace and type, and one file per member except where the model + combines case-insensitive filename collisions onto a shared page). Types declaring + operator overloads receive a dedicated `operators.md` page grouping all operators + with C# method-signature headings and summaries. Nested types appear in a + "Nested Types" table on the containing type page and receive dedicated pages under + the containing type's folder. - **DotNetEmitterSingleFile** — writes all documentation into a single api.md file. - **TypeLinkResolver** — resolves Mono.Cecil TypeReference instances to Markdown link text for use in table cells. @@ -27,12 +37,10 @@ defined by the Core interfaces. The system contains eleven units: - **XmlDocReader** — reads and indexes a .NET XML documentation file for fast member-level lookups; resolves `` references using the inheritance chain map supplied by DotNetGenerator. -- **ApiVisibility** — enum controlling which members are included in the output - (`Public`, `PublicAndProtected`, `All`). -- **DotNetGeneratorOptions** — configuration value object passed to the - DotNetGenerator constructor. -- **ExternalTypeInfo** — internal record representing a non-standard external type - reference collected during table cell generation. + +Three support types — `ApiVisibility`, `DotNetGeneratorOptions`, and `ExternalTypeInfo` — +are defined alongside their owning units in `DotNetGenerator.cs` and `TypeLinkResolver.cs` +and are not counted as separate units. ```mermaid flowchart TD @@ -42,10 +50,12 @@ flowchart TD DotNetEmitter --> DotNetEmitterGradualDisclosure DotNetEmitter --> DotNetEmitterSingleFile DotNetEmitter --> IMarkdownWriterFactory + DotNetGenerator --> DotNetEmitter DotNetEmitterGradualDisclosure --> TypeLinkResolver - DotNetEmitterGradualDisclosure --> TypeNameSimplifier - DotNetEmitterSingleFile --> TypeNameSimplifier + DotNetEmitterSingleFile --> TypeLinkResolver + DotNetEmitter --> TypeNameSimplifier TypeLinkResolver --> TypeNameSimplifier + TypeLinkResolver --> DotNetEmitter ``` ## External Interfaces @@ -63,7 +73,7 @@ ApiMarkCore; parsing is separated from emit via the two-stage pipeline. writes the full Markdown tree for the configured assembly using the supplied factory and the format selected by `config`. - *Constraints*: DotNetGeneratorOptions must be fully populated before calling - Parse; AssemblyPath and XmlDocPath must reference files that exist on disk. + Parse; AssemblyPath and XmlDocPath must both reference files that exist on disk. **Mono.Cecil (consumed)**: DotNetGenerator uses Mono.Cecil to read assembly metadata. @@ -83,14 +93,13 @@ from its caller to create each Markdown output file. path it needs to write. - *Constraints*: Must not be null at Emit call time. -**IContext (consumed)**: DotNetGenerator accepts an IContext from its caller but does -not currently write any messages through it during generation. The parameter is -accepted for interface compliance and reserved for future diagnostic and progress -messages. +**IContext (consumed)**: DotNetGenerator accepts an IContext from its caller and +emits progress messages through it during `Parse`. - *Type*: In-process .NET interface from ApiMarkCore. -- *Role*: Consumer (reserved) — DotNetGenerator accepts the context but does not - currently call any methods on it. +- *Role*: Consumer — `DotNetGenerator.Parse` calls `IContext.WriteLine` to emit + two progress messages: one before opening the assembly (naming the assembly file) + and one after type collection (reporting the type count and namespace count). - *Constraints*: Must not be null at Parse and Emit call time. ## Dependencies @@ -112,20 +121,38 @@ N/A - not a safety-classified software item. 2. DotNetGenerator calls `AssemblyDefinition.ReadAssembly` (Mono.Cecil) to load type and member metadata from disk without loading the assembly into the AppDomain. 3. DotNetGenerator parses the XML documentation file and indexes entries by member - identifier string. + identifier string. During this phase, `DotNetGenerator.Parse` recognizes + `internal static class NamespaceDoc` carrier types: they are excluded from the + visible type listing, and their XML summary is promoted to the namespace + description dictionary keyed by namespace name. 4. DotNetEmitter selects the active emitter sub-component (DotNetEmitterGradualDisclosure or DotNetEmitterSingleFile) based on EmitConfig.Format. For gradual-disclosure output, DotNetEmitterGradualDisclosure calls `factory.CreateMarkdown("", "api")` and writes the assembly-level entrypoint file listing all namespaces. -5. For each namespace, DotNetEmitterGradualDisclosure calls `factory.CreateMarkdown(namespaceFolderPath, - namespaceName)` and writes a namespace summary listing all visible types. -6. For each visible type, DotNetEmitterGradualDisclosure writes every member to its own dedicated - file via `factory.CreateMarkdown(namespaceFolderPath, typeName)` and links all members - from the type page. Each member receives its own page, except where case-insensitive - filename collisions on a single type require combining colliding members onto one - shared page. +5. For each namespace, DotNetEmitterGradualDisclosure splits the namespace folder path into a + subfolder and short name, then calls `factory.CreateMarkdown(subFolder, shortName)` and writes + a namespace summary listing all visible types. +6. For each visible type, DotNetEmitterGradualDisclosure writes member pages using three + distinct page-generation strategies: (1) individual detail pages per member via + `factory.CreateMarkdown(namespaceFolderPath, typeName)` — the default for all non-colliding + members; (2) collision-combined pages for case-insensitive filename collisions on a single + type, where two or more members whose sanitized names differ only in case are merged onto one + shared page named after the lower-invariant key; and (3) an operators group page + (`operators.md`) for types that declare operator overloads, grouping all operators with C# + method-signature headings and summaries. All members are linked from the type page. Nested + types appear in a "Nested Types" table on the containing type page and receive dedicated + pages under the containing type's folder via additional `factory.CreateMarkdown` calls. 7. TypeNameSimplifier is called for each type reference encountered during output generation, producing simplified C# type names relative to the current namespace. +8. As TypeLinkResolver resolves each table cell it accumulates non-System external type + references into a per-file `ISet` supplied by the emitter. After + all table rows for a page are written, DotNetEmitterGradualDisclosure emits the + "External Types" section listing all tracked external types in alphabetical order, if + any were collected. DotNetEmitterSingleFile does not emit External Types sections + because single-file output is a linear document intended for human consumption (e.g., + PDF compilation). A global external-type list stripped of per-member context provides + little navigational value in that format; external type information is most useful when + scoped to the individual page on which the type appears. ## Design Constraints @@ -137,3 +164,17 @@ N/A - not a safety-classified software item. System.Reflection API must not be used for assembly reflection. - Visibility filter: the Visibility option (Public, PublicAndProtected, All) must be applied before any member is written to output. + +## Error Handling + +- **Invalid file paths**: `DotNetGenerator.Parse` throws `FileNotFoundException` if + `AssemblyPath` or `XmlDocPath` do not exist on disk. The exception propagates to the + caller unchanged. +- **Null arguments**: `DotNetGenerator(DotNetGeneratorOptions)` throws + `ArgumentNullException` if `options` is null. `DotNetGenerator.Parse(IContext)` + throws `ArgumentNullException` if `context` is null. `IApiEmitter.Emit(factory, config, + context)` throws `ArgumentNullException` if any of `factory`, `config`, or `context` + is null. +- **Malformed XML documentation**: `XmlDocReader` silently ignores malformed or missing + XML doc elements — unrecognised elements produce empty/null lookups rather than + exceptions, so generation continues with placeholder text where doc content is absent. diff --git a/docs/design/api-mark-dot-net/dot-net-ast-model.md b/docs/design/api-mark-dot-net/dot-net-ast-model.md index 96f04e2..02b7317 100644 --- a/docs/design/api-mark-dot-net/dot-net-ast-model.md +++ b/docs/design/api-mark-dot-net/dot-net-ast-model.md @@ -8,8 +8,10 @@ DotNetAstModel is an immutable data class that holds all parsed .NET assembly data required during the emit phase. It is created exclusively by `DotNetGenerator.Parse` and transferred to `DotNetEmitter`. All properties are -read-only after construction, so the emitter can safely share the model across -its internal helper methods without defensive copies. +read-only after construction and the collections they expose use read-only +interfaces (`IReadOnlyList`, `IReadOnlyDictionary`), making the model's entire +observable state immutable after construction. The emitter can safely share the +model across its internal helper methods without defensive copies. The three context records defined in the same file — `TypePageWriteContext`, `MethodDocContext`, and `NamespaceDocContext` — reduce parameter counts on the @@ -23,15 +25,14 @@ boundary between parse and emit. - *Assembly* (`AssemblyDefinition`): The Mono.Cecil assembly definition held open for the duration of emit. Ownership is transferred to the model on - construction; the `AssemblyDefinition` is disposed by `DotNetEmitter.Emit` - in its `finally` block after the emit run completes or throws. + construction; the `AssemblyDefinition` is disposed via a `using (Model.Assembly)` block in `DotNetEmitter.Emit` after the emit run completes or throws. - *XmlDocs* (`XmlDocReader`): Pre-built XML documentation reader for O(1) per-member lookups by XML doc identifier string. -- *AllNamespaces* (`List`): All namespace names present in the assembly, +- *AllNamespaces* (`IReadOnlyList`): All namespace names present in the assembly, ordered alphabetically (ordinal). -- *ByNamespace* (`Dictionary>`): Visible types +- *ByNamespace* (`IReadOnlyDictionary>`): Visible types grouped by their namespace name. -- *RootNamespaces* (`List`): Root namespace names identified during +- *RootNamespaces* (`IReadOnlyList`): Root namespace names identified during parse. Used by `DotNetEmitter.GetNamespaceFolderPath` to compute file-system paths. - *NamespaceDescriptions* (`IReadOnlyDictionary`): Optional @@ -67,9 +68,9 @@ callers do not need to thread five constant parameters through each call site. namespace documentation context that is constant across all namespace page writes in a single generation run. -- *AllNamespaces* (`List`): All namespaces in alphabetical order. -- *ByNamespace* (`Dictionary>`): Types grouped by namespace. -- *RootNamespaces* (`List`): Root namespaces for path computation. +- *AllNamespaces* (`IReadOnlyList`): All namespaces in alphabetical order. +- *ByNamespace* (`IReadOnlyDictionary>`): Types grouped by namespace. +- *RootNamespaces* (`IReadOnlyList`): Root namespaces for path computation. - *NamespaceDescriptions* (`IReadOnlyDictionary`): Optional namespace summaries. - *XmlDocs* (`XmlDocReader`): Documentation index. - *Resolver* (`TypeLinkResolver`): Type link resolver. @@ -80,8 +81,8 @@ writes in a single generation run. read-only properties. - *Parameters*: `AssemblyDefinition assembly`, `XmlDocReader xmlDocs`, - `List allNamespaces`, `Dictionary> byNamespace`, - `List rootNamespaces`, `IReadOnlyDictionary namespaceDescriptions`, + `IReadOnlyList allNamespaces`, `IReadOnlyDictionary> byNamespace`, + `IReadOnlyList rootNamespaces`, `IReadOnlyDictionary namespaceDescriptions`, `TypeLinkResolver resolver`, `DotNetGeneratorOptions options`. - *Preconditions*: No parameter may be null. - *Postconditions*: All properties are initialized; no mutation is possible. @@ -107,3 +108,7 @@ responsibility of `DotNetGenerator.Parse` before constructing the model. gradual-disclosure emission. - **DotNetEmitterSingleFile** — reads all model properties during single-file emission. + +### External Interfaces + +N/A — this is an internal class with no external interfaces exposed beyond its assembly. diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index 760d3c2..80a567e 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -29,6 +29,56 @@ DotNetEmitterGradualDisclosure holds references to: **EmitGradualDisclosure** (private): Writes the assembly index page, then iterates all namespaces and types, writing namespace summary and type pages. +After writing the H1 heading, the method emits the `AssemblyDescriptionAttribute` +value as a paragraph when the attribute is present on the assembly. The +all-namespaces table uses three columns: `Namespace`, `Types`, and `Description`, +where `Types` contains the direct type count for each namespace. + +**WriteNamespacePage** (private): Writes the Markdown summary page for a +single namespace. + +- *Parameters*: `IMarkdownWriterFactory factory` — factory used to create the + namespace page writer; `string namespaceName` — the full namespace name being + documented; `NamespaceDocContext ctx` — bundled namespace documentation context + shared across all namespace page writes. +- *Returns*: `void` +- *Algorithm*: Computes the namespace folder path and splits it into subfolder + and short name; creates the namespace page writer via `factory.CreateMarkdown`; + writes an H1 heading with the namespace name; if a namespace description is + present in `NamespaceDescriptions`, emits it as a paragraph; writes a table of + immediate child namespaces with links when any exist; writes a table of all + visible types in the namespace (columns: Type, Description), with each type + name linked to its type page; calls `WriteTypePage` for each type. + +**WriteTypePage** (private): Writes the Markdown type page for a single +`TypeDefinition`. + +- *Parameters*: `TypePageWriteContext ctx` — type-level write context encapsulating + factory, namespace name, namespace folder path, type definition, XML docs, and + resolver. +- *Returns*: `void` +- *Algorithm*: Creates the type page writer internally via `ctx.Factory.CreateMarkdown`; + writes an H1 heading with the type's simple name; emits the C# signature + via `writer.WriteSignature("csharp", ...)`, which produces a fenced C# code block; emits the XML summary and remarks paragraphs; emits structured example blocks via `ctx.XmlDocs.GetExampleParts(typeMemberId)`; groups all + visible members by kind (Constructors, Properties, Fields, Events, Methods, + Operators, Nested Types) and writes one table row per member (or one representative row + per method overload group) with a link to the member's dedicated page; calls per-kind page writers for each member or member group. + +**WriteMethodDocumentation** (private static): Writes the XML documentation content +for a single method (or one overload) onto the caller-supplied writer. + +- *Parameters*: `IMarkdownWriter writer` — writer receiving the documentation sections; + `MethodDefinition method` — the method to document; `string memberId` — the + XML-doc member ID used for lookup; `MethodDocContext context` — namespace name, + XML doc reader, resolver, current folder, and external type accumulator. +- *Returns*: `void` +- *Algorithm*: Emits the C# method signature via `writer.WriteSignature("csharp", ...)`, which produces a fenced C# code block; writes the XML + summary as a paragraph (or the placeholder when absent); if the method has parameters, + writes a parameter table with Parameter, Type, and Description columns — type cells are + resolved via `resolver.Linkify` and external types are accumulated into + `context.ExternalTypes`; if a returns value is documented, writes a `**Returns:**` + paragraph; writes exception and remarks sections when present; writes structured + example blocks from `xmlDocs.GetExampleParts`. **WriteMethodOverloadPage** (private static): Writes a single shared Markdown page for a pure method overload group (all methods sharing the same exact case-sensitive sanitized file name). @@ -68,6 +118,93 @@ human-readable kind string (`"Field"`, `"Property"`, `"Event"`, `"Constructor"`, section at the bottom of a page when at least one external type was referenced in table cells. +**WriteTypeOperatorsPage** (private static): Writes the `operators.md` page for +a type that defines operator overloads. + +- *Parameters*: `IMarkdownWriterFactory factory`, `string namespaceName`, + `string namespaceFolderPath`, `TypeDefinition type`, + `IReadOnlyList operatorMethods` — the operator methods to + document, `XmlDocReader xmlDocs`, `TypeLinkResolver resolver`. +- *Returns*: `void` +- *Algorithm*: Creates `{namespaceFolderPath}/{FlattenArity(type.Name)}/operators.md` + via the factory; writes an H1 heading; for each operator method writes an H2 + heading using `BuildMethodDisplayName`, which produces a C# method-signature form + including the operator keyword and full parameter list, and delegates to + `WriteMethodDocumentation`; accumulates external type references and emits them + via `WriteExternalTypesSection`. + +**WriteNestedTypePage** (via recursive `WriteTypePage`): Writes a dedicated Markdown page for each visible nested type under its containing type's folder. + +- *Parameters*: `TypePageWriteContext ctx` — context for the nested type, where `NamespaceFolderPath` is set to `{parentNamespaceFolderPath}/{FlattenArity(containingTypeName)}` and `Type` is the nested type definition. +- *Returns*: `void` +- *Path pattern*: `factory.CreateMarkdown("{namespaceFolderPath}/{FlattenArity(containingTypeName)}", nestedTypeName)` — the nested type's page is placed under its containing type's folder segment. +- *Algorithm*: Called from `WriteTypePage` for each element of `GetVisibleNestedTypes(ctx.Type)`. Each nested type's page is written by recursively calling `WriteTypePage` with the updated context; the nested type's own members are iterated and documented on that page following the same process as any other type page. + +**WriteMemberPage** (private static): Creates the dedicated detail page for a single +non-overloaded, non-collision member. + +- *Parameters*: `TypePageWriteContext ctx` — type-level write context providing factory, + namespace name, namespace folder path, type definition, XML docs, and resolver. + `IMemberDefinition member` — the member to document. `string memberId` — the pre-computed + XML-doc member ID for documentation lookups. +- *Returns*: `void` +- *Algorithm*: Computes `sanitizedName` via `GetSanitizedMemberFileName`; creates the page + writer at `{namespaceFolderPath}/{FlattenArity(type.Name)}/{sanitizedName}.md`; writes an H1 + heading using `GetMemberDisplayName`; if `member` is a `MethodDefinition`, creates a + `SortedSet`, calls `WriteMethodDocumentation`, and emits the external types + section via `WriteExternalTypesSection`; otherwise delegates to + `WriteNonMethodMemberContent` with an empty external types accumulator. + +**ProcessSingleMember** (private static): Handles a single non-overloaded, +non-collision member by writing its dedicated page and adding one row to the +appropriate per-kind accumulator on the containing type page. + +- *Parameters*: `TypePageWriteContext ctx`, `IMemberDefinition member`, + plus per-kind row accumulators and an `externalTypes` accumulator. +- *Returns*: `void` +- *Algorithm*: Computes the member ID, summary, type name, display name, and + sanitized file name; creates the member's dedicated page via `WriteMemberPage`; + adds one table row to `constructorRows`, `methodRows`, `propertyRows`, + `fieldRows`, or `eventRows` depending on the member kind. + +**ProcessOverloadGroup** (private static): Handles a pure method overload group +by writing a single consolidated overload page and adding one representative row +to the appropriate per-kind accumulator. + +- *Parameters*: `TypePageWriteContext ctx`, + `IReadOnlyList group` — all overloads sharing the same key, + plus `constructorRows`, `methodRows`, and `externalTypes` accumulators. +- *Returns*: `void` +- *Algorithm*: Orders overloads by generic-parameter count, then by value-parameter + count, then by parameter type name list (deterministic selection of representative); + calls `WriteMethodOverloadPage`; adds one row linking to the shared overload page, + using `GetMethodGroupDisplayName` for the display text. + +**ProcessCollisionMember** (private static): Handles one member from a +case-insensitive filename collision group; writes the combined page on first +encounter and adds the member's row to the appropriate per-kind accumulator. + +- *Parameters*: `TypePageWriteContext ctx`, `IMemberDefinition member`, + `IReadOnlyList group`, `string lowerKey`, + `HashSet writtenLowerKeys`, per-kind row accumulators, and `externalTypes`. +- *Returns*: `void` +- *Algorithm*: Calls `WriteCombinedMemberPage` when `writtenLowerKeys.Add(lowerKey)` + succeeds (first visit); regardless, adds one row for this member linking to the + shared `{lowerKey}.md` page. + +**WriteNonMethodMemberContent** (private static): Writes the signature, summary, +returns, exceptions, remarks, and example documentation sections for a single +non-method member (property, field, or event) to the supplied Markdown writer. + +- *Parameters*: `IMarkdownWriter writer`, `IMemberDefinition member`, + `string memberId`, `MethodDocContext ctx` — provides namespace name, XML docs, + resolver, current folder, and external type accumulator. +- *Returns*: `void` +- *Algorithm*: Emits the C# member signature via `writer.WriteSignature("csharp", ...)`, which produces a fenced C# code block; writes the XML + XML summary as a paragraph (or the placeholder when absent); emits returns, + exceptions, remarks, and example sections when present, following the same + pattern as `WriteMethodDocumentation` for non-method member kinds. + ### Path Conventions The assembly index page (`api.md`) also writes a `## File Naming and Path Convention` @@ -81,10 +218,13 @@ member, and operators page patterns. - Namespace summary: `factory.CreateMarkdown(subFolder, shortName)` where `subFolder` and `shortName` are produced by splitting `namespaceFolderPath` at the last separator (e.g. for `ApiMark.DotNet.Fixtures`, `subFolder=""` and `shortName="ApiMark.DotNet.Fixtures"`) -- Type page: `factory.CreateMarkdown(namespaceFolderPath, typeSimpleName)` -- Member detail: `factory.CreateMarkdown("{namespaceFolderPath}/{typeSimpleName}", memberName)` -- Operators page: `factory.CreateMarkdown("{namespaceFolderPath}/{typeSimpleName}", "operators")` -- Combined page: `factory.CreateMarkdown("{namespaceFolderPath}/{typeSimpleName}", lowerKey)` +- Type page: `factory.CreateMarkdown(namespaceFolderPath, FlattenArity(type.Name))` + where `FlattenArity` replaces backtick-arity notation (e.g. `` SampleGenericClass`1 ``) + with a plain numeric suffix form (`SampleGenericClass1`) for file-system safety. This is distinct + from `StripArity`, which removes the arity suffix entirely and is used for display names. +- Member detail: `factory.CreateMarkdown("{namespaceFolderPath}/{FlattenArity(type.Name)}", memberName)` +- Operators page: `factory.CreateMarkdown("{namespaceFolderPath}/{FlattenArity(type.Name)}", "operators")` +- Combined page: `factory.CreateMarkdown("{namespaceFolderPath}/{FlattenArity(type.Name)}", lowerKey)` ### Error Handling @@ -98,7 +238,6 @@ this class. - **DotNetAstModel** — provides assembly data. - **TypeLinkResolver** — used to resolve type references to Markdown links in table cells. -- **TypeNameSimplifier** — used to build type signature strings. - **XmlDocReader** — used to retrieve documentation text for each member. - **IMarkdownWriterFactory** — received from `DotNetEmitter.Emit`. @@ -106,3 +245,7 @@ this class. - **DotNetEmitter.Emit** — constructs and calls this class when the format is not SingleFile. + +### External Interfaces + +N/A — this is an internal class with no external interfaces exposed beyond its assembly. diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md b/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md index 4e34077..72a2dcc 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md @@ -28,6 +28,10 @@ into that writer at heading levels `HeadingDepth` (assembly title), `HeadingDepth+1` (namespace), `HeadingDepth+2` (type), and `HeadingDepth+3` (individual members). +- If the assembly carries an `AssemblyDescriptionAttribute`, its value is emitted + as a paragraph immediately after the assembly-level H{depth} heading. +- If a namespace has a summary supplied via the `NamespaceDoc` convention, that + summary is emitted as a paragraph below the H{depth+1} namespace heading. - Each type section includes a compact bullet list of members (`- **Name**: summary`) before the H(depth+3) member sections. - No group headings (`Constructors`, `Methods`, `Properties`) or convention @@ -42,9 +46,23 @@ bullet list, and then dispatches to `WriteSingleFileMemberSection` for each visible member. Recursively calls `WriteSingleFileNestedTypes` when the type contains nested types. +- For nested types, a notice paragraph `"Nested type of \`{OuterType}\`."` is + emitted immediately after the H{depth+2} heading to establish parent context. +- When the type is a delegate (detected via `DotNetEmitter.IsDelegate`), the + method returns early after emitting the declaration, summary, remarks, and + example sections; compiler-injected Invoke/BeginInvoke/EndInvoke members are + not emitted as they are not meaningful API content. +- Visible members are ordered with constructors first (by name `.ctor`), then + all remaining members alphabetically by name. + **WriteSingleFileMemberSection** (private): Writes the full per-member block for one member — member heading, signature code block, summary, parameter table, -returns documentation, exception table, and example block. +returns documentation, exception table, and example block. A throw-away empty +`SortedSet` is passed to each `resolver.Linkify` call; because +`generateLinks` is `false` and no External Types section is emitted in single-file +output, this set is never populated or read. Note that `namespaceFolderPath` is +passed to `resolver.Linkify` as a required parameter even though it is not used for +link generation when `generateLinks` is `false`. **WriteSingleFileNestedTypes** (private): Recursively emits documentation for nested types within a type section by calling `WriteSingleFileTypeSections` for @@ -60,15 +78,17 @@ this class. - **DotNetEmitter** — parent emitter providing shared static helpers. - **DotNetAstModel** — provides assembly data. -- **TypeNameSimplifier** — used to build type signature strings. +- **TypeLinkResolver** — constructed with `generateLinks: false` to produce plain-text + type names without Markdown link generation; used when rendering parameter type cells + in single-file output. - **XmlDocReader** — used to retrieve documentation text for each member. - **IMarkdownWriterFactory** — received from `DotNetEmitter.Emit`. -### External Interfaces - -N/A - DotNetEmitterSingleFile is an internal class with no external interfaces. - ### Callers - **DotNetEmitter.Emit** — constructs and calls this class when `config.Format == OutputFormat.SingleFile`. + +### External Interfaces + +N/A - DotNetEmitterSingleFile is an internal class with no external interfaces. diff --git a/docs/design/api-mark-dot-net/dot-net-emitter.md b/docs/design/api-mark-dot-net/dot-net-emitter.md index 2fbc376..62049f8 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter.md @@ -10,8 +10,7 @@ two format-specific sub-emitters: `DotNetEmitterGradualDisclosure` and `DotNetEmitterSingleFile`. It reads `EmitConfig.Format` and forwards the factory, config, and context to the appropriate sub-emitter. It also provides shared static helper methods (type signature builders, visibility filters, -namespace path helpers, and combined-member-page writers) used by both -sub-emitters. +and namespace path helpers) used by both sub-emitters. ### Data Model @@ -40,9 +39,9 @@ Markdown documentation tree in the format specified by `config.Format`. Markdown writers; must not be null. `EmitConfig config` — output configuration. `IContext context` — forwarded to the selected sub-emitter. - *Returns*: `void` -- *Algorithm*: Validates that `factory` is not null (throws `ArgumentNullException` - otherwise); opens a `using (Model.Assembly)` block to ensure disposal; if - `config.Format == OutputFormat.SingleFile`, creates and calls +- *Algorithm*: Validates that `factory`, `config`, and `context` are not null (throws + `ArgumentNullException` for any null argument); opens a `using (Model.Assembly)` block + to ensure disposal; if `config.Format == OutputFormat.SingleFile`, creates and calls `DotNetEmitterSingleFile.Emit`; otherwise creates and calls `DotNetEmitterGradualDisclosure.Emit`. @@ -59,53 +58,162 @@ file-system folder path for a namespace, treating the root namespace as atomic. C# declaration signature for a type definition. - *Parameters*: `TypeDefinition type`, `string contextNamespace`. -- *Returns*: `string` — e.g. `public class Name`, `public interface Name`, - or `public class Name : BaseClass, IInterface`. +- *Returns*: `string` — e.g. `public class Name`, `public abstract class Name`, + `public interface Name`, or `public class Name : BaseClass, IInterface`. +- *Algorithm (modifier selection)*: For `class` keyword types only: + - `IsAbstract && IsSealed` → `static` modifier (C# static classes compile to + abstract+sealed in IL). + - `IsAbstract && !IsSealed` → `abstract` modifier. + - `IsSealed && !IsAbstract` → `sealed` modifier. + - Otherwise → no modifier (regular class). + Delegates, interfaces, enums, and structs are handled by earlier branches and + never enter the modifier-selection block. **Shared Helper Methods** (internal static): DotNetEmitter exposes shared helper methods consumed by DotNetEmitterGradualDisclosure and DotNetEmitterSingleFile. These helpers are grouped by concern: -- *Visibility filters* — `IsTypeVisible`, `IsMemberVisible`, `GetVisibleMembers`, - `ShouldIncludeMember`: determine which types and members are included based on - the configured visibility level and `IncludeObsolete` flag. -- *ID and file-name builders* — `BuildMemberId`, `BuildMethodId`, - `BuildMemberFileName`, `BuildMethodFileName`, `GetMethodGroupName`: produce the - XML-doc member IDs and sanitized file-name segments used by both emitters. -- *Signature builders* — `BuildMemberSignature`, `BuildMethodSignature`, - `BuildPropertySignature`, `BuildFieldSignature`, `BuildEventSignature`: produce - the human-readable C# declaration strings written into code-fence blocks. -- *Type predicates* — `IsOperator`, `IsDelegate`, `IsSpecialNameNonConstructor`, - `IsExtensionMethod`, `IsBackingField`, `IsNamespaceDocCarrier`: categorize - members to drive conditional rendering paths. -- *Accessibility helpers* — `GetAccessibilityKeyword` (overloads for - `TypeDefinition`, `MethodDefinition`, `FieldDefinition`, `PropertyDefinition`, - `EventDefinition`): map Mono.Cecil access flags to C# keywords. -- *Type utilities* — `GetMemberTypeRef`, `IsMemberTypeNullableAnnotated`, - `StripArity`, `SanitizeFileName`: extract type references, detect nullable - annotations, and produce filesystem-safe name segments. +- *Namespace path helpers* — `GetNamespaceFolderPath`, `GetImmediateChildNamespaces`, + `SplitPath`: compute namespace folder paths and enumerate direct child namespaces. +- *Visibility filters* — `IsTypeVisible`, `GetVisibleNestedTypes`, `IsMemberVisible`, + `IsMemberPublic`, `IsMemberPublicOrProtected`, `IsPropertyPublicOrProtected`, + `GetVisibleMembers`, `ShouldIncludeMember`: determine which types and members are + included based on the configured visibility level and `IncludeObsolete` flag. +- *Type/member classification* — `IsOperator`, `IsSpecialNameNonConstructor`, + `IsCompilerGeneratedField`, `IsDelegate`, `IsExtensionMethod`, + `IsCompilerGenerated(ICustomAttributeProvider)`, `IsCompilerGenerated(TypeDefinition)`, + `IsObsolete`, `IsNamespaceDocCarrier`: categorize types and members to drive + conditional rendering paths. +- *ID and file-name builders* — `GetMemberDisplayName`, `BuildTypeId`, `BuildMemberId`, + `BuildMethodId`, `GetSanitizedMemberFileName`, `BuildMethodDisplayName`, + `BuildMethodFileName`, `GetMethodGroupDisplayName`, `GetMethodGroupName`: produce + the XML-doc member IDs, display names, and sanitized file-name segments used by + both emitters. +- *Signature builders* — `BuildTypeSignature`, `BuildDelegateSignature`, + `BuildMemberSignature`, `BuildMethodSignature`, `BuildPropertySignature`, + `BuildPropertyAccessors`, `BuildFieldSignature`, `BuildEventSignature`, + `BuildOperatorSignature`: produce the human-readable C# declaration strings + written into code-fence blocks. Conversion operators produce signatures of the form + `public static implicit operator TargetType(SourceType)` or + `public static explicit operator TargetType(SourceType)`, with the return type + appearing after the `operator` keyword rather than before the method name. +- *Accessibility helpers* — `GetAccessibilityKeyword(MethodDefinition)`, + `GetAccessibilityKeyword(FieldDefinition)`, `GetAccessibilityKeyword(EventDefinition)`, + `GetOperatorCSharpName`, `GetOperatorSymbol`: map Mono.Cecil access flags to C# + keywords; property accessibility is determined directly from get/set accessor + visibility rather than a dedicated overload. +- *Nullable/annotation helpers* — `GetMemberTypeRef`, `IsMemberTypeNullableAnnotated`, + `HasNullableAnnotation`: extract type references and detect nullable reference + annotations from Mono.Cecil custom attribute data. +- *Utility* — `StripArity`, `FlattenArity`, `ToXmlDocTypeName`: strip or reformat + generic arity markers, and convert Cecil full names to XML-doc ID encoding. + +**DotNetEmitter.StripArity** (internal static): Removes the generic arity suffix (e.g. +`` `1 ``) from a type name. + +- *Parameters*: `string name` — the raw IL type name that may contain a backtick arity + suffix (e.g. `Dictionary\`2`). +- *Returns*: `string` — the name with the backtick and arity digit removed entirely + (e.g. `Dictionary\`2` → `Dictionary`). Returns the input unchanged when no backtick is + present. +- *Algorithm*: Locates the first backtick character in `name`; if found, returns the + substring before it; otherwise returns the input unchanged. Does not delegate to + `TypeNameSimplifier`; the implementation is self-contained in `DotNetEmitter`. +- *Contrast with `FlattenArity`*: `StripArity` removes the arity digit entirely (for + display names), while `FlattenArity` removes only the backtick but preserves the digit + (for filesystem-safe path segments). + +**DotNetEmitter.FlattenArity** (internal static): Converts a generic type's IL +name to a filesystem-safe display form by removing the backtick while retaining +the arity digit. + +- *Parameters*: `string name` — the raw IL type name that may contain a backtick + arity suffix (e.g. `Dictionary\`2`). +- *Returns*: `string` — the name with the backtick removed but the arity digit + preserved (e.g. `Dictionary\`2` → `Dictionary2`). Returns the input unchanged + when no backtick is present. +- *Note*: This method delegates to `TypeNameSimplifier.FlattenArity`. +- *Used by*: Both sub-emitters when constructing folder and file paths for generic + types, so that `List\`1` produces a filesystem path segment `List1` rather than + embedding a backtick in the path. + +**DotNetEmitter.BuildPropertyAccessors** (internal static): Builds the accessor +portion of a property signature. + +- *Parameters*: `PropertyDefinition prop` — the property to build accessors for. +- *Returns*: `string` — the accessor string (e.g. `get; set;`, `get; init;`, + `get; private set;`). +- *Algorithm*: Collects `get;` (when a getter exists) and `set;` or `init;` (when a + setter exists) into a list. For the getter, a less-permissive-than-property accessor + prefix is included; for the setter, the keyword is `init;` when the `SetMethod` + return type carries a `RequiredModifierType` for + `System.Runtime.CompilerServices.IsExternalInit` (the Mono.Cecil representation of a + C# 9+ init-only setter), otherwise `set;`. An accessor prefix is included only when + the accessor's declared accessibility is strictly less permissive than the property's + declared accessibility. + +**DotNetEmitter.ToXmlDocTypeName** (internal static): Converts a Mono.Cecil +`TypeReference` full name to an XML documentation ID string suitable for member-key +lookup in `XmlDocReader`. + +- *Parameters*: `string cecilFullName` — the Mono.Cecil `TypeReference.FullName` string. +- *Returns*: `string` — the normalized XML-doc type name string. +- *Algorithm*: Strips generic arity markers (backtick + digit(s)) from type names and + replaces square-bracket generic parameter notation (found in some Cecil full-name + representations) with XML-doc `{` / `}` type-parameter format; normalizes + nested-type separators from `/` (Cecil) to `.` (XML-doc format). +- *Used by*: `BuildMethodId` when constructing the XML-doc member ID for method lookups + in `XmlDocReader`. ### Error Handling -`DotNetEmitter.Emit` throws `ArgumentNullException` when `factory` is null. -All other exceptions (Mono.Cecil I/O errors, XmlDocReader errors) propagate -unchanged to the caller. The `AssemblyDefinition` is always disposed in a +`DotNetEmitter.Emit` throws `ArgumentNullException` when any of `factory`, `config`, or +`context` is null. All other exceptions (Mono.Cecil I/O errors, XmlDocReader errors) +propagate unchanged to the caller. The `AssemblyDefinition` is always disposed in a `finally` block regardless of success or failure. ### Dependencies - **IApiEmitter** — DotNetEmitter implements this interface from ApiMarkCore. -- **EmitConfig** — DotNetEmitter reads `Format` and `HeadingDepth`. +- **EmitConfig** — DotNetEmitter reads `Format` to select the sub-emitter; `HeadingDepth` is consumed by `DotNetEmitterSingleFile` to offset all heading levels and is not used by `DotNetEmitterGradualDisclosure`, which writes separate files each beginning at H1. - **IMarkdownWriterFactory** — received through `Emit`; forwarded to sub-emitters. - **DotNetAstModel** — held by reference; passed to sub-emitters. - **DotNetEmitterGradualDisclosure** — sub-emitter created and called by Emit. - **DotNetEmitterSingleFile** — sub-emitter created and called by Emit. -- **TypeNameSimplifier** — used by `BuildTypeSignature` to produce idiomatic - C# type names. +- **TypeNameSimplifier** — used by `BuildTypeSignature` and related signature-builder + helpers to produce idiomatic C# type names; `DotNetEmitter.FlattenArity` delegates + to `TypeNameSimplifier.FlattenArity` for file-system-safe generic type name segments. +- **Mono.Cecil** — DotNetEmitter uses Mono.Cecil types (`TypeDefinition`, `MethodDefinition`, + `PropertyDefinition`, `FieldDefinition`, `EventDefinition`, `TypeReference`, `RequiredModifierType`) + directly in the signatures and bodies of its static helper methods (signature builders, + XML-doc ID builders, nullable annotation helpers, and member classification helpers). See + the OTS Mono.Cecil integration design (`docs/design/ots/mono-cecil.md`). ### Callers - **DotNetGenerator.Parse** — constructs a DotNetEmitter wrapping the parsed - DotNetAstModel and returns it to the caller as an IApiEmitter. + DotNetAstModel and returns it to the caller as an IApiEmitter. During Parse, + DotNetGenerator also calls four static helpers on DotNetEmitter directly: + `IsNamespaceDocCarrier` (to identify NamespaceDoc carrier types for exclusion + and namespace description extraction), `IsCompilerGenerated` (to exclude + compiler-generated types from the visible type list), `IsObsolete` (to filter + obsolete types when `IncludeObsolete` is false), and `BuildTypeId` (to construct + the XML-doc member ID for each NamespaceDoc carrier's summary lookup). +- **DotNetEmitterGradualDisclosure** — holds a `_emitter` reference and calls + shared static helpers (`BuildTypeSignature`, `GetNamespaceFolderPath`, + `GetMemberDisplayName`, `FlattenArity`, and others) throughout gradual-disclosure + emission. +- **DotNetEmitterSingleFile** — holds a `_emitter` reference and calls shared + static helpers (`BuildTypeSignature`, `GetNamespaceFolderPath`, + `GetMemberDisplayName`, and others) throughout single-file emission. +- **TypeLinkResolver** — calls the static helper `GetNamespaceFolderPath` to + compute documentation folder paths for intra-assembly type links. - **ApiMarkTask** — calls `Emit` on the returned IApiEmitter. - **Program** — calls `Emit` on the returned IApiEmitter. + +### External Interfaces + +DotNetEmitter implements `IApiEmitter` (from `ApiMark.Core`), which exposes a single +`Emit(IMarkdownWriterFactory, EmitConfig, IContext)` method. This contract is the integration +point between the language-agnostic Core pipeline and the .NET-specific emission logic. +See the ApiMarkCore system design for the full `IApiEmitter` contract. diff --git a/docs/design/api-mark-dot-net/dot-net-generator.md b/docs/design/api-mark-dot-net/dot-net-generator.md index 03e754c..ef431c1 100644 --- a/docs/design/api-mark-dot-net/dot-net-generator.md +++ b/docs/design/api-mark-dot-net/dot-net-generator.md @@ -8,20 +8,14 @@ DotNetGenerator implements IApiGenerator for C#/.NET assemblies. It reads a compiled .dll assembly via Mono.Cecil and pairs its type and member metadata with documentation from the associated XML documentation file. It applies visibility -filtering, uses TypeNameSimplifier to produce idiomatic C# type names, and — -depending on `EmitConfig.Format` — either writes a gradual-disclosure Markdown -tree (one file per concept) or a single-file Markdown document through -`IMarkdownWriterFactory`. Every visible member normally gets its own dedicated -detail page in gradual-disclosure mode; members whose names collide on -case-insensitive filesystems are combined onto a single shared page. +filtering, builds an inheritance chain for `` resolution, and +constructs a `DotNetAstModel` wrapped in a `DotNetEmitter` — which is responsible +for format selection and Markdown writing. The split across implementation units is: -The implementation is split across eleven files in the `ApiMark.DotNet` package: - -- **ApiVisibility.cs** — `ApiVisibility` enum controlling which members are included in output. - **DotNetGenerator.cs** — thin `IApiGenerator` that parses the assembly and - returns a `DotNetEmitter`. -- **DotNetGeneratorOptions.cs** — `DotNetGeneratorOptions` configuration value object - passed to the `DotNetGenerator` constructor. + returns a `DotNetEmitter`. Also defines the `ApiVisibility` enum and the + `DotNetGeneratorOptions` configuration value object alongside the generator + they configure. - **DotNetAstModel.cs** — `DotNetAstModel` data class holding all parsed namespace and type data, plus the context records used during page generation. See DotNetAstModel Design for full details. @@ -30,13 +24,13 @@ The implementation is split across eleven files in the `ApiMark.DotNet` package: - **DotNetEmitterGradualDisclosure.cs** — all gradual-disclosure page writers (namespace, type, member, operator, and external-types pages). - **DotNetEmitterSingleFile.cs** — all single-file page writers. -- **ExternalTypeInfo.cs** — `ExternalTypeInfo` internal record representing a - non-standard external type reference collected during table cell generation. - **TypeLinkResolver.cs** — resolves Mono.Cecil type references to Markdown link - text for use in table cells. See TypeLinkResolver Design for full details. + text for use in table cells. Also defines the `ExternalTypeInfo` internal record + that is produced and consumed by the resolver. + See TypeLinkResolver Design for full details. - **TypeNameSimplifier.cs** — simplifies CLR type names into idiomatic C# display text. See TypeNameSimplifier Design for full details. -- **XmlDocReader.cs** — reads and indexes the XML documentation file for O(1) +- **XmlDocReader.cs** — reads and indexes the XML documentation file for indexed per-member lookups. See XmlDocReader Design for full details. ### Data Model @@ -56,16 +50,10 @@ are included in the output. Values: `Public` (public members only), `PublicAndProtected` (public and protected members), `All` (all members regardless of access modifier). -**DotNetGeneratorOptions.IncludeObsolete**: `bool` — when false, members marked +**DotNetGeneratorOptions.IncludeObsolete**: `bool` — when false, types and members marked with `[Obsolete]` are excluded from the output. -**ExternalTypeInfo** (internal record): Represents a non-standard external type -reference collected during table cell generation. - -- *Properties*: `SimplifiedName` (display form, may include escaped generic - angle brackets), `Namespace` (the type's .NET namespace). -- *Ordering*: implements `IComparable` by `SimplifiedName` so - `SortedSet` produces alphabetically ordered tables. +**ExternalTypeInfo**: See `TypeLinkResolver` design for the `ExternalTypeInfo` data record. **DotNetAstModel** (internal sealed class): Holds all pre-parsed assembly data bridging the parse and emit phases. See DotNetAstModel Design for the full @@ -77,20 +65,20 @@ list of properties and context records. instance for use during Parse. - *Parameters*: `DotNetGeneratorOptions options` — fully populated options object. -- *Preconditions*: `options` must not be null; `AssemblyPath` and `XmlDocPath` must - be non-empty strings. +- *Preconditions*: `options` must not be null. - *Postconditions*: The generator instance is ready to call Parse. **DotNetGenerator.Parse**: Reads the assembly and XML documentation file into memory and returns a `DotNetEmitter` ready to emit. -- *Parameters*: `IContext context` — output channel reserved for future diagnostic - and progress messages; DotNetGenerator does not currently emit any messages - through this channel during parsing. +- *Parameters*: `IContext context` — output channel for informational messages. + DotNetGenerator emits two progress messages during parsing: one before opening the + assembly (naming the assembly file) and one after type collection (reporting the + type count and namespace count) via `IContext.WriteLine`. - *Returns*: `IApiEmitter` — a `DotNetEmitter` holding all parsed namespace, type, and member data. - *Preconditions*: `AssemblyPath` and `XmlDocPath` must exist on disk; `context` - must not be null. + must not be null. `Parse` throws `ArgumentNullException` when `context` is null. - *Postconditions*: The returned emitter holds the parsed `AssemblyDefinition`, namespace index, an XML documentation reader, and the inheritance-chain map built from Mono.Cecil metadata and passed to `XmlDocReader` for bare `` @@ -98,32 +86,19 @@ memory and returns a `DotNetEmitter` ready to emit. If `BuildInheritanceChain` or `XmlDocReader` construction throws, the `AssemblyDefinition` is disposed before the exception propagates (resource leak prevention via try/catch). - -**DotNetEmitter.Emit** (implements `IApiEmitter`): Writes the full Markdown output -tree using the format specified by `config.Format`. - -- *Parameters*: `IMarkdownWriterFactory factory` — factory used to create each - Markdown output file; must not be null. `EmitConfig config` — output - configuration. `IContext context` — forwarded to the selected sub-emitter. -- *Returns*: `void` -- *Preconditions*: `factory` must not be null. -- *Postconditions (GradualDisclosure)*: The factory has produced a complete - Markdown tree for the configured assembly. Output file naming follows: - - `factory.CreateMarkdown("", "api")` — assembly entrypoint. - - `factory.CreateMarkdown(namespaceFolderPath, namespaceName)` — namespace summary. - - `factory.CreateMarkdown(namespaceFolderPath, typeSimpleName)` — type page. - - `factory.CreateMarkdown($"{namespaceFolderPath}/{typeSimpleName}", memberName)` — - dedicated file for every visible member; case-insensitive name collisions on a - single type are combined onto one shared page instead of separate pages. -- *Postconditions (SingleFile)*: A single `api.md` is created via - `factory.CreateMarkdown("", "api")` containing the full documentation tree - at heading levels `HeadingDepth` (assembly), `HeadingDepth+1` (namespace), - `HeadingDepth+2` (type), `HeadingDepth+3` (member). Each type section includes - a bullet list of members (`- **Name**: summary`) before the H(depth+3) member - sections. No group headings (Constructors, Methods, etc.) or convention appendix - are emitted. -- The `AssemblyDefinition` is always disposed in a `finally` block after Emit - completes or throws. +- *NamespaceDoc processing*: After collecting all visible types, `Parse` calls + `DotNetEmitter.IsNamespaceDocCarrier` on each type. Carrier types (those named + `NamespaceDoc` with `internal static` modifiers) are excluded from the type + listings passed to the emitter. Their XML summary is extracted via + `XmlDocReader.GetSummary` using the type's XML-doc ID and stored in the + `NamespaceDescriptions` dictionary of `DotNetAstModel`, keyed by namespace name, + for use when writing namespace pages. +- *Visibility note*: At the top-level type enumeration stage, `PublicAndProtected` + behaves identically to `Public` because C# does not permit protected top-level + types. The distinction between `Public` and `PublicAndProtected` is applied at + the member level by `DotNetEmitter`. +- *TypeLinkResolver scope*: `TypeLinkResolver` is constructed with `rootNamespaces` only (not all namespaces), which constrains which type references can produce intra-assembly Markdown links; types whose namespace does not map to a known root namespace path fall back to plain text. +- *NamespaceDoc selection*: When multiple `NamespaceDoc` carrier types exist in the same namespace, `FirstOrDefault` over non-empty summaries selects the namespace description; the first carrier type whose XML summary is non-empty wins. **DotNetGenerator.BuildInheritanceChain** (private static): Builds a member-ID to ordered base-member-ID map from Mono.Cecil metadata for use during `` resolution. @@ -148,121 +123,37 @@ base-member-ID map from Mono.Cecil metadata for use during `` reso explicit or implicit interface target in declaration order. - *Known limitation*: Complex generic signatures may not always map perfectly to XML-doc IDs because Mono.Cecil `FullName` uses `/` for nested-type separators whereas XML-doc - format uses `.`; callers should treat resolution failures as a no-op rather than an error. - -**DotNetEmitter.BuildTypeSignature** (internal static): Builds a human-readable C# -declaration signature for a type definition, including direct base class and -interface names when present. - -- *Parameters*: `TypeDefinition type` — the type to represent; - `string contextNamespace` — the namespace used to simplify base type and - interface names. -- *Returns*: `string` — a declaration of the form `public class Name`, - `public interface Name`, or `public class Name : BaseClass, IInterface` - when direct inheritance is present. -- *Algorithm*: Determines the keyword (`class`, `interface`, `enum`, or `struct`) - from the type's flags; computes the `sealed` or `static` modifier for classes; - strips generic arity from the type name and appends generic parameter names when - present; collects the direct base class (skipping `System.Object`, - `System.ValueType`, `System.Enum`, and `System.MulticastDelegate`) and all - directly declared interfaces using TypeNameSimplifier to produce idiomatic C# - names; appends `: BaseClass, IInterface` when the collected list is non-empty. - -**DotNetEmitterGradualDisclosure.WriteCombinedMemberPage** (private static): Writes a single combined -Markdown page for a group of members whose sanitized file names collide on -case-insensitive filesystems. - -- *Parameters*: `IMarkdownWriterFactory factory`, `string namespaceName`, - `string namespaceFolderPath`, `TypeDefinition type`, `string lowerKey` — the - shared lowercase file name key used as the page file name and H1 heading, - `IReadOnlyList members` — the ordered collision group (at - least two elements), `XmlDocReader xmlDocs` — documentation index. -- *Returns*: `void` -- *Algorithm*: Creates `{namespaceFolderPath}/{FlattenArity(type.Name)}/{lowerKey}.md` - via the factory, where `FlattenArity` strips any generic arity backtick suffix - (e.g. `List\`1` → `List`); writes an H1 heading using`lowerKey`; for each member - writes an H2 heading of the form`{displayName} ({kindLabel})`; for - `MethodDefinition` members delegates to `WriteMethodDocumentation`; for all other - member kinds writes the signature, summary, returns, exceptions, remarks, and - example sections directly. - -**DotNetEmitterGradualDisclosure.IsPureMethodOverloadGroup** (private static): Returns true when all -members in a group are methods sharing the same exact case-sensitive sanitized -file name, indicating a classical method overload group rather than a -case-insensitive collision. - -- *Parameters*: `IReadOnlyList group` — candidate group sharing - a lowercase key; `TypeDefinition type` — declaring type required by - `GetSanitizedMemberFileName`. -- *Returns*: `bool` — true when every element is a `MethodDefinition` and all - share the same exact (ordinal) file name; false otherwise. -- *Algorithm*: Returns false immediately if any element is not a - `MethodDefinition`; computes the sanitized file name for the first element and - checks that all remaining elements produce the same value under ordinal - comparison. - -**DotNetEmitterGradualDisclosure.GetMemberKindLabel** (private static): Maps an `IMemberDefinition` to -a short human-readable kind string used in combined page H2 headings. - -- *Parameters*: `IMemberDefinition member` — the member to classify. -- *Returns*: `string` — one of `"Field"`, `"Property"`, `"Event"`, - `"Constructor"`, `"Method"`, or `"Member"` (fallback for unknown kinds). -- *Algorithm*: Pattern-matches on the concrete type: `FieldDefinition` → `"Field"`; - `PropertyDefinition` → `"Property"`; `EventDefinition` → `"Event"`; - `MethodDefinition` with name `.ctor` → `"Constructor"`; `MethodDefinition` → - `"Method"`; all other types → `"Member"`. - -**TypeLinkResolver** (internal): Resolves Mono.Cecil `TypeReference` instances -to Markdown link text for use in table cells. - -- *Constructor*: Accepts `IReadOnlyList rootNamespaces` — forwarded to - `DotNetEmitter.GetNamespaceFolderPath` when computing target page paths; and - optional `bool generateLinks = true` — when `false`, intra-assembly types - render as plain text (used by `DotNetEmitterSingleFile`). -- **Linkify** method: resolves a `TypeReference` to a Markdown link string. - - *Parameters*: `TypeReference typeRef`, `string currentFolder` (path of the - containing file), `string contextNamespace`, `ISet - externalTypes` accumulator, optional `bool isNullableAnnotated`. - - *Returns*: a Markdown link when the type is intra-assembly; the original - simplified name otherwise; external non-System types are tracked in - `externalTypes`. - - *Rules*: `Nullable` → `T?` via recursion; array types → `elementText[]`; - generic instance types linkify the container when intra-assembly; primitives - and `System.*` types render as plain text; non-System external types are - added to the accumulator. - - Intra-assembly detection: `TypeReference.Scope is ModuleDefinition`. - -**DotNetEmitterGradualDisclosure.WriteExternalTypesSection** (private static): Emits the -`## External Types` section at the bottom of a page when at least one external -type was referenced in table cells. - -- *Parameters*: `IMarkdownWriter writer`, `SortedSet - externalTypes`. -- *Algorithm*: Returns immediately when the set is empty; otherwise writes an - H2 heading `"External Types"` and a two-column table (`Type`, `Namespace`). + format uses `.`; resolution failures are silently treated as a no-op. `XmlDocReader` + degrades gracefully when a lookup finds no matching chain entry. ### Error Handling -DotNetGenerator throws `FileNotFoundException` explicitly when XmlDocPath does not -exist on disk (checked before opening the assembly). If AssemblyPath does not exist -or is not a valid .NET assembly, Mono.Cecil raises an exception that propagates -unchanged to the caller (ApiMarkTask or Program). Missing XML documentation entries -for a member produce empty documentation fields rather than an error. -`ArgumentNullException` is thrown by `DotNetEmitter.Emit` when `factory` is null. +`Parse` first checks whether `context` is null, throwing `ArgumentNullException` if +so. It then checks whether `AssemblyPath` exists on disk, throwing `FileNotFoundException` +if absent. It then checks whether `XmlDocPath` exists, throwing `FileNotFoundException` if +absent. Only after both checks pass does it invoke Mono.Cecil to open the assembly. Missing +XML documentation entries for a member produce empty documentation fields rather than an error. +`ArgumentNullException` is thrown by the `DotNetGenerator` constructor when `options` +is null. ### Dependencies - **IApiGenerator** — DotNetGenerator implements this interface from ApiMarkCore. - **IApiEmitter** — DotNetEmitter implements this interface from ApiMarkCore. -- **EmitConfig** — DotNetEmitter reads `EmitConfig.Format` and - `EmitConfig.HeadingDepth` to determine the output structure. - **IMarkdownWriterFactory** — DotNetEmitter receives an IMarkdownWriterFactory through Emit and calls CreateMarkdown to obtain each IMarkdownWriter. It does - not implement IMarkdownWriter itself. + not implement IMarkdownWriter itself. (DotNetGenerator creates DotNetEmitter, + which owns this dependency.) +- **DotNetEmitter** — provides the static helper methods `IsNamespaceDocCarrier`, + `IsCompilerGenerated`, `IsObsolete`, and `BuildTypeId` called by DotNetGenerator + during Parse to classify and identify types in the assembly. - **TypeNameSimplifier** — DotNetEmitter calls TypeNameSimplifier to convert - Mono.Cecil type references to idiomatic C# type names in output. + Mono.Cecil type references to idiomatic C# type names in output. (DotNetGenerator + creates DotNetEmitter, which owns this dependency.) - **XmlDocReader** — DotNetGenerator constructs an XmlDocReader from XmlDocPath during - Parse to parse and index the XML documentation file for O(1) per-member lookups. + Parse to parse and index the XML documentation file for indexed per-member lookups. +- **TypeLinkResolver** — constructed during Parse with `rootNamespaces` and stored in + `DotNetAstModel.Resolver` for use during emission. - **Mono.Cecil** — used to read assembly metadata without loading the assembly into the current process — see Mono.Cecil Integration Design. @@ -272,3 +163,10 @@ for a member produce empty documentation fields rather than an error. Parse, then Emit. - **Program** — constructs DotNetGenerator from CLI options and calls Parse, then Emit. + +### External Interfaces + +DotNetGenerator implements `IApiGenerator` (from `ApiMark.Core`), which exposes `Parse(IContext)` +returning `IApiEmitter`. This contract is the integration point between the language-agnostic +Core pipeline and the .NET-specific parsing logic. See the ApiMarkCore system design for the +full `IApiGenerator` contract. diff --git a/docs/design/api-mark-dot-net/type-link-resolver.md b/docs/design/api-mark-dot-net/type-link-resolver.md index 68cba18..b3e1deb 100644 --- a/docs/design/api-mark-dot-net/type-link-resolver.md +++ b/docs/design/api-mark-dot-net/type-link-resolver.md @@ -32,6 +32,19 @@ parameter. C# primitive types that are always rendered as their keyword alias and never tracked as external dependencies. +**ExternalTypeInfo** (internal record): Represents a non-System external type +reference collected during table cell generation. + +- *SimplifiedName* (`string`): The type's simplified display name (may include + escaped generic angle brackets). +- *Namespace* (`string`): The type's .NET namespace. +- *Ordering*: Implements `IComparable` for deterministic + alphabetical ordering by `SimplifiedName` then `Namespace` — used when emitting + the External Types table to ensure a stable, predictable sort order so the + generated section is reproducible across runs. The record also implements four + comparison operators (`<`, `<=`, `>`, `>=`) as mechanical companions to + `IComparable`. + ### Key Methods **TypeLinkResolver constructor**: Accepts the root namespaces and an optional @@ -69,7 +82,8 @@ parameter. intra-assembly, tracks external otherwise. 6. Primitive or `System.Nullable<>` open type → `TypeNameSimplifier.Simplify`, append `?` if nullable annotated. - 7. Intra-assembly (scope is `ModuleDefinition`) → relative Markdown link. + 7. Intra-assembly (scope is `ModuleDefinition`) → relative Markdown link when `_generateLinks` + is `true`; plain-text type name when `_generateLinks` is `false`. 8. Non-System external → track in `externalTypes`, return plain name. - *Intra-assembly detection*: `TypeReference.Scope is ModuleDefinition`. @@ -82,16 +96,30 @@ No other exceptions are thrown by TypeLinkResolver itself; exceptions from ### Dependencies - **TypeNameSimplifier** — called to produce simplified type names for primitive - types and as display text for generic arguments. + types and as display text for generic arguments. `TypeNameSimplifier.FlattenArity` + converts a backtick arity suffix to a plain numeric suffix (e.g. `List\`1` → + `List1`) for use in file-path generation, while`TypeNameSimplifier.StripArity` + removes the backtick suffix entirely (e.g. `List\`1` → `List`) for display name + generation. These two methods serve distinct purposes and must not be substituted + for each other. - **DotNetEmitter.GetNamespaceFolderPath** — called to compute the documentation folder path for intra-assembly types. - **Mono.Cecil** — TypeLinkResolver operates on Mono.Cecil type reference objects. ### Callers -- **DotNetEmitter** — uses TypeLinkResolver (via DotNetAstModel.Resolver) to - build link text in type signature and table cell generation. +The direct callers of TypeLinkResolver are the two sub-emitters and the generator +that constructs it. `DotNetEmitter` does not call TypeLinkResolver directly; it holds +a `DotNetAstModel` that carries the resolver, making it available to sub-emitters via +`model.Resolver`. + +- **DotNetGenerator** — constructs `DotNetAstModel`, which holds the + `TypeLinkResolver` instance created during `Parse`. - **DotNetEmitterGradualDisclosure** — calls `Linkify` for each type reference encountered in member table rows. - **DotNetEmitterSingleFile** — constructs a TypeLinkResolver with `generateLinks: false` for parameter type display in single-file output. + +### External Interfaces + +N/A — this is an internal class with no external interfaces exposed beyond its assembly. diff --git a/docs/design/api-mark-dot-net/type-name-simplifier.md b/docs/design/api-mark-dot-net/type-name-simplifier.md index da84308..92d2af0 100644 --- a/docs/design/api-mark-dot-net/type-name-simplifier.md +++ b/docs/design/api-mark-dot-net/type-name-simplifier.md @@ -22,9 +22,14 @@ keyword aliases). Checked in Rule 1, but composite arms (array, nullable, generi are evaluated first; primitive-alias resolution applies to leaf types only. **WellKnownNamespaces** (`private static readonly HashSet`): contains the -namespace prefixes that are stripped when displaying generic or plain types. -Current entries: `System.Collections.Generic` and `System.Threading.Tasks`. This -field is the intended place to add new prefixes as the tool evolves. +namespace prefixes that are stripped when simplifying the container name of +**generic instance types** inside `BuildGenericName`. Current entries: +`System.Collections.Generic` and `System.Threading.Tasks`. This field governs +namespace stripping only for generic type containers — plain (non-generic, +non-aliased) types always reduce to their simple name via the fallthrough arm of +`Simplify` regardless of namespace, so `WellKnownNamespaces` does not affect +plain type rendering. This field is the intended place to add new prefixes as +the tool evolves. ### Key Methods @@ -57,13 +62,16 @@ Simplification rules applied in order: governs namespace stripping for generic type arguments only. 2. **Array syntax** — `System.ArrayType` with element type `T` is rendered as `T[]` using the Mono.Cecil `ArrayType.ElementType` property; the element type is itself - simplified recursively. + simplified recursively. Multi-dimensional arrays use rank-aware bracket notation: + a one-dimensional array produces `T[]`, a two-dimensional array produces `T[,]`, a + three-dimensional array produces `T[,,]`, and so on — computed as + `"[" + new string(',', rank - 1) + "]"`. 3. **Nullable value types** — `System.Nullable` is rendered as `T?` rather than `Nullable`; the inner type is simplified recursively. 4. **Well-known namespace stripping** — the `WellKnownNamespaces` set lists namespace prefixes whose types are commonly understood without qualification. Current entries: `System.Collections.Generic` (so `List`, - `Dictionary`, `IEnumerable` etc. appear unqualified) and + `Dictionary`, `IEnumerable` etc. appear unqualified) and `System.Threading.Tasks` (so `Task` and `Task` appear unqualified). Adding a new entry to `WellKnownNamespaces` is the only change required to extend this behavior. @@ -85,8 +93,11 @@ suffix from a type name. suffix (e.g. `List\`1`). - *Returns*: `string` — the name without the arity suffix (e.g. `List`). - *Callers*: TypeLinkResolver (when computing external type display names and - type page keys), DotNetEmitterGradualDisclosure (when building type page names - and member sanitized file names). + type page keys). + +> **Note**: DotNetEmitter contains a parallel self-contained `StripArity` +> implementation that shares the same algorithm but does not delegate to this +> method. **TypeNameSimplifier.FlattenArity** (internal static): Converts the IL backtick arity suffix to a plain numeric suffix, producing a file-system-safe name that @@ -97,10 +108,10 @@ still distinguishes generic types by parameter count. - *Returns*: `string` — the name with the backtick removed but the arity count preserved (e.g. `Foo2`). Unchanged when no backtick is present. - *Callers*: TypeLinkResolver (when computing type page keys via `GetTypePageKey`), - and DotNetEmitterGradualDisclosure (via the `DotNetEmitter.FlattenArity` delegate - wrapper) when building namespace page links, type page names, member page subfolder - paths, and operator page paths. This list is non-exhaustive — any code in the - DotNet system that constructs file-system paths for types may call this method. + and DotNetEmitter (via the `DotNetEmitter.FlattenArity` delegate wrapper) when + building namespace page links, type page names, member page subfolder paths, and + operator page paths. This list is non-exhaustive — any code in the DotNet system + that constructs file-system paths for types may call this method. ### Error Handling @@ -117,11 +128,15 @@ can rely on always receiving a non-null string. ### Callers -- **DotNetEmitter** — calls TypeNameSimplifier.Simplify for every type reference - encountered while building method signatures, property types, field types, and - parameter lists during Markdown generation, passing the current type's namespace - as `contextNamespace`. +- **DotNetEmitter** — calls TypeNameSimplifier via `BuildTypeSignature` and related + signature-builder helper methods (including `BuildMemberSignature`, `BuildDelegateSignature`, + `BuildPropertySignature`, `BuildFieldSignature`, `BuildEventSignature`, and + `BuildMethodSignature`) to produce idiomatic C# declaration strings for all output pages. - **TypeLinkResolver** — calls TypeNameSimplifier.Simplify to produce the plain-text display name of primitive and System types used as link display text, and calls TypeNameSimplifier.StripArity and FlattenArity when computing type page keys and external type display names. + +### External Interfaces + +N/A — this is an internal class with no external interfaces exposed beyond its assembly. diff --git a/docs/design/api-mark-dot-net/xml-doc-reader.md b/docs/design/api-mark-dot-net/xml-doc-reader.md index 19b2fdb..38f6fd7 100644 --- a/docs/design/api-mark-dot-net/xml-doc-reader.md +++ b/docs/design/api-mark-dot-net/xml-doc-reader.md @@ -74,6 +74,11 @@ format names); entries with an empty cref are filtered out. Resolves the `` element is absent or contains only whitespace. Resolves `` first. +> **Note**: `GetExample` returns raw text content using `element?.Value.Trim()` +> rather than the inline element rendering pipeline. Inline elements such as +> ``, ``, and `` within `` are silently dropped by +> `GetExample`. Use `GetExampleParts` for full inline-element rendering. + **GetExampleParts**: Returns the structured example content for `memberId` as `IReadOnlyList<(bool IsCode, string Content)>` parts. When the `` element contains no `` children, the entire text is returned as a single @@ -81,13 +86,22 @@ code part after applying `DedentCode`. When `` children are present, text nodes become prose parts and `` elements become code parts with `DedentCode` applied to each code block. Resolves `` first. +> **`` flush behavior**: When a `` element is encountered among the +> mixed-content children of ``, its text is rendered into the prose +> accumulator and then the accumulator is immediately flushed as a distinct prose +> part. This ensures that each `` produces its own separate prose part +> rather than merging with adjacent text content. + **ResolveMemberElement** (private): Resolves the effective `` element for a given ID by following `` recursively with cycle detection. - Returns the member element directly when no `` child is present. - When `cref` is present, resolves the named target recursively. - When no `cref` is present, tries each candidate from `_inheritanceChain` in - order, stopping at the first that yields a result. + order, stopping at the first that yields a result. Each candidate is tried with + a branch-local copy of the visited set. This ensures that a failed traversal + through one candidate — which may visit shared ancestor nodes — does not prevent + subsequent candidates from resolving through those same ancestors. - When `path` is present (XPath expression), evaluates it against the resolved source element and wraps matching nodes in a synthetic `` element. - Maintains a `HashSet` of visited IDs per resolution path to break @@ -98,6 +112,38 @@ collapsing internal whitespace within each line. `GetSingleLineDocumentationText additionally joins all non-empty trimmed lines into a single space-separated string. Both normalize line endings to `\n` before processing. +#### Inline Element Rendering + +`GetDocumentationText` processes inline XML elements within doc comment nodes +according to the following element-to-text mappings: + +- `text` → CommonMark backtick code span; the fence length adapts to avoid + embedded backticks in the content per CommonMark §6.1. When the code content starts + or ends with a backtick, a single space is inserted on each side inside the fence so + that Markdown parsers can unambiguously identify the fence delimiter (CommonMark §6.1). +- `` → formatted cref value via the `FormatCref` helper (strips the + type-kind prefix, strips the namespace path to leave just the type name, and replaces + generic arity markers with angle-bracket type-parameter placeholders via + `FormatTypeArity`, e.g. `List\`1` → `List`,`Dictionary\`2` → `Dictionary`). +- `` → the `langword` attribute value directly (e.g., `null`, + `true`, `false`). +- `` and `` → the `name` attribute + value directly. +- Consecutive non-`` child nodes in `` → accumulated into a single + prose text part; `` child nodes → separate code parts (dedented via + `DedentCode`). + +**FormatCref** (private static): Converts a raw `cref` attribute value to a concise +display string. + +- *Algorithm*: Strips the type-kind prefix (`T:`, `M:`, `P:`, `F:`, `E:`); strips + the namespace path from the remaining qualified name to leave just the type and + member name; replaces generic arity markers (`` `1 ``, `` `2 ``) with angle-bracket + type-parameter placeholder notation via `FormatTypeArity` (e.g., `List\`1` → + `List`,`Dictionary\`2` → `Dictionary`); replaces`#ctor` with the + declaring type name for constructor crefs; appends `()` to method crefs that + include a parameter list. + **Code block dedentation** (`DedentCode`, private static): Removes common leading indentation from raw `` element content so the result renders flush-left in a fenced Markdown code block. The minimum indentation is computed from the leading @@ -107,13 +153,6 @@ from every line. Leading and trailing blank lines are removed from the final result. Returns `string.Empty` for whitespace-only input so existing `string.IsNullOrEmpty` guards remain effective. -**cref formatting** (`FormatCref`, private static): Strips the type-kind -prefix (`T:`, `M:`, `P:`, `F:`, `E:`) from a cref value and formats the -result as a readable name. Constructor crefs (`#ctor`) are replaced by the -type name. Method crefs with parameters append `()`. Type crefs are simplified -using `FormatTypeName`, which applies C# primitive aliases for well-known CLR -names. - ### Error Handling `XmlDocReader` throws `FileNotFoundException` when the XML documentation file @@ -137,3 +176,7 @@ chain entries degrade gracefully to `null` or empty. member detail pages. - **DotNetEmitterSingleFile** — calls getter methods when writing member sections in the single-file output. + +### External Interfaces + +N/A — this is an internal class with no external interfaces exposed beyond its assembly. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 15868fe..8963743 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -105,57 +105,16 @@ OTS Dependencies: ```text src/ -├── ApiMark.Core/ -│ ├── IApiGenerator.cs - interface every language generator must implement -│ ├── IApiEmitter.cs - interface for writing parsed results as Markdown output -│ ├── EmitConfig.cs - value object controlling output format and heading depth -│ ├── OutputFormat.cs - enum selecting GradualDisclosure or SingleFile output -│ ├── IContext.cs - minimal output channel that generators use to emit messages -│ ├── IMarkdownWriterFactory.cs - factory interface for creating per-file markdown writers -│ ├── IMarkdownWriter.cs - per-file markdown writing interface (IDisposable) -│ ├── PathHelpers.cs - shared path-safety helper for combining validated relative paths -│ ├── GlobFileCollector.cs - shared glob-based file discovery utility (absolute/relative patterns, extension inference) -│ ├── FileMarkdownWriterFactory.cs - file-system implementation of IMarkdownWriterFactory -│ └── FileMarkdownWriter.cs - file-system implementation of IMarkdownWriter -├── ApiMark.DotNet/ -│ ├── ApiVisibility.cs - enum controlling which members are included in output -│ ├── DotNetGenerator.cs - C#/.NET IApiGenerator implementation -│ ├── DotNetGeneratorOptions.cs - configuration options for the .NET generator -│ ├── DotNetAstModel.cs - holds pre-parsed assembly data for the emit phase -│ ├── DotNetEmitter.cs - dispatches to single-file or gradual-disclosure emitter -│ ├── DotNetEmitterGradualDisclosure.cs - writes multiple-file gradual-disclosure output -│ ├── DotNetEmitterSingleFile.cs - writes single-file output -│ ├── TypeLinkResolver.cs - resolves Mono.Cecil type references to Markdown links -│ ├── TypeNameSimplifier.cs - simplifies rendered .NET type references -│ └── XmlDocReader.cs - parses XML documentation files produced by the C# compiler -├── ApiMark.Cpp/ -│ ├── ApiVisibility.cs - enum controlling which members are included in output -│ ├── CppGenerator.cs - C++ IApiGenerator implementation -│ ├── CppGeneratorOptions.cs - configuration options for the C++ generator -│ ├── CppEmitter.cs - dispatches to single-file or gradual-disclosure C++ emitter -│ ├── CppEmitterGradualDisclosure.cs - writes multiple-file gradual-disclosure C++ output -│ ├── CppEmitterSingleFile.cs - writes single-file C++ output -│ ├── CppTypeLinkResolver.cs - resolves C++ type strings to Markdown links -│ └── CppAst/ -│ ├── CppAstModel.cs - C++ AST data model (types, functions, namespaces) -│ └── ClangAstParser.cs - invokes clang -ast-dump=json and parses the AST -├── ApiMark.Vhdl/ -│ ├── VhdlGenerator.cs - VHDL IApiGenerator implementation -│ ├── VhdlGeneratorOptions.cs - configuration options for the VHDL generator -│ ├── VhdlEmitter.cs - dispatches to single-file or gradual-disclosure VHDL emitter -│ ├── VhdlEmitterGradualDisclosure.cs - writes multiple-file gradual-disclosure VHDL output -│ ├── VhdlEmitterSingleFile.cs - writes single-file VHDL output -│ └── VhdlAst/ -│ ├── VhdlAstModel.cs - VHDL AST data model (entities, architectures, packages) -│ └── VhdlAstParser.cs - parses .vhd files using ANTLR4 vhdl2008 grammar -├── ApiMark.MSBuild/ -│ └── ApiMarkTask.cs - MSBuild task that spawns ApiMark.Tool out-of-process -└── ApiMark.Tool/ - ├── Cli/ - │ └── Context.cs - command-line context with standard flags and language options - ├── SelfTest/ - │ └── Validation.cs - self-validation tests for --validate - └── Program.cs - dotnet CLI entry point dispatching to IApiGenerator +├── ApiMark.Core/ - shared contracts, file-path helpers, and Markdown writer implementations +├── ApiMark.DotNet/ - C#/.NET language generator +├── ApiMark.Cpp/ - C++ language generator +│ └── CppAst/ - C++ AST data model and clang parser +├── ApiMark.Vhdl/ - VHDL language generator +│ └── VhdlAst/ - VHDL AST data model and ANTLR4 parser +├── ApiMark.MSBuild/ - MSBuild task that spawns ApiMark.Tool out-of-process +└── ApiMark.Tool/ - CLI entry point + ├── Cli/ - command-line argument parsing and context construction + └── SelfTest/ - self-validation subsystem ``` ## Companion Artifact Structure diff --git a/docs/reqstream/api-mark-dot-net.yaml b/docs/reqstream/api-mark-dot-net.yaml index 2f96e54..d432342 100644 --- a/docs/reqstream/api-mark-dot-net.yaml +++ b/docs/reqstream/api-mark-dot-net.yaml @@ -3,7 +3,7 @@ sections: - title: ApiMarkDotNet Requirements requirements: - - id: ApiMarkDotNet-DotNetGenerator-GenerateDocumentationFromAssembliesAndXml + - id: ApiMarkDotNet-GenerateDocumentationFromAssembliesAndXml title: ApiMarkDotNet shall generate API documentation from .NET assemblies and XML documentation files. justification: | @@ -16,21 +16,48 @@ sections: - ApiMarkDotNet-DotNetGenerator-ReadXmlDocumentationComments - ApiMarkDotNet-DotNetGenerator-FilterByVisibility - ApiMarkDotNet-DotNetGenerator-IncludeObsoleteApisWhenRequested - - ApiMarkDotNet-DotNetGenerator-EmitDetailPageForEveryMember - - ApiMarkDotNet-DotNetGenerator-ProduceConsistentOutputFileStructure - - ApiMarkDotNet-DotNetGenerator-DocumentOperatorOverloads - - ApiMarkDotNet-DotNetGenerator-DocumentNestedTypes - - ApiMarkDotNet-DotNetGenerator-EmitCombinedMemberPageForCaseInsensitiveCollisions - - ApiMarkDotNet-DotNetGenerator-ApiMdListsAllNamespacesWithTypeCount - - ApiMarkDotNet-DotNetGenerator-ShowDirectInheritanceInTypeSignature - - ApiMarkDotNet-DotNetGenerator-EmitIntraDocLinksInTableCells - - ApiMarkDotNet-DotNetGenerator-EmitExternalTypesSection - - ApiMarkDotNet-DotNetGenerator-EmitSingleFileOutput + - ApiMarkDotNet-DotNetGenerator-RecognizeNamespaceDocCarrierClass + - ApiMarkDotNet-DotNetGenerator-ExcludeNamespaceDocCarrierFromTypeListing + - ApiMarkDotNet-DotNetGenerator-UseNamespaceDocSummaryAsNamespaceDescription + - ApiMarkDotNet-DotNetGenerator-RejectMissingAssemblyPath + - ApiMarkDotNet-DotNetGenerator-RejectMissingXmlDocPath + - ApiMarkDotNet-DotNetGenerator-RejectNullOptions + - ApiMarkDotNet-DotNetGenerator-RejectNullContext + - ApiMarkDotNet-DotNetGenerator-BuildInheritanceChainForInheritDoc - ApiMarkDotNet-DotNetAstModel-HoldParsedAssemblyData - ApiMarkDotNet-DotNetEmitter-DispatchToFormatSpecificEmitter - - ApiMarkDotNet-DotNetEmitterGradualDisclosure-ProduceMultiFileTree - - ApiMarkDotNet-DotNetEmitterSingleFile-ProduceSingleApiMd - - ApiMarkDotNet-TypeLinkResolver-ResolveTypeReferencesToMarkdownLinks + - ApiMarkDotNet-DotNetEmitter-RejectNullArguments + - ApiMarkDotNet-DotNetEmitter-ComputeNamespaceFolderPath + - ApiMarkDotNet-DotNetEmitter-ShowDirectInheritanceInTypeSignature + - ApiMarkDotNet-DotNetEmitter-EmitOperatorsPageWithSymbolHeadings + - ApiMarkDotNet-DotNetEmitter-EmitConversionOperators + - ApiMarkDotNet-DotNetEmitter-EmitConversionOperatorsWithConversionSyntax + - ApiMarkDotNet-DotNetEmitter-RenderClassModifiersInTypeSignature + - ApiMarkDotNet-DotNetEmitter-RenderInitOnlyPropertyAccessor + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitApiIndexPage + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitNamespacePages + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitTypePages + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitMemberDetailPages + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitCombinedPageForCaseCollisions + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitMethodOverloadPage + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitOperatorsPage + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitNestedTypePages + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitOperatorsSummaryDocumentation + - ApiMarkDotNet-DotNetEmitterSingleFile-EmitAllApiIntoSingleFile + - ApiMarkDotNet-DotNetEmitterSingleFile-OffsetHeadingsByHeadingDepth + - ApiMarkDotNet-DotNetEmitterSingleFile-EmitAssemblyDescription + - ApiMarkDotNet-DotNetEmitterSingleFile-EmitNamespaceDocSummary + - ApiMarkDotNet-DotNetEmitterSingleFile-EmitCompactMemberBulletList + - ApiMarkDotNet-DotNetEmitterSingleFile-EmitMembersConstructorsFirst + - ApiMarkDotNet-DotNetEmitterSingleFile-SkipMemberListForDelegates + - ApiMarkDotNet-DotNetEmitterSingleFile-EmitNestedTypeParentNotice + - ApiMarkDotNet-DotNetEmitterSingleFile-EmitPlainTextParameterTypes + - ApiMarkDotNet-TypeLinkResolver-ResolveIntraAssemblyTypesToMarkdownLinks + - ApiMarkDotNet-TypeLinkResolver-ResolvePrimitiveAndSystemTypesToPlainText + - ApiMarkDotNet-TypeLinkResolver-TrackNonSystemExternalTypesForExternalTypesSection + - ApiMarkDotNet-TypeLinkResolver-RenderArrayTypesWithSuffix + - ApiMarkDotNet-TypeLinkResolver-RenderGenericTypesWithTypeArguments + - ApiMarkDotNet-TypeLinkResolver-UnwrapNullableValueTypes - ApiMarkDotNet-XmlDocReader-ParseAndIndexXmlDocumentation - ApiMarkDotNet-XmlDocReader-InheritDoc tests: [ApiMarkDotNet_Generate_ValidAssemblyAndXml_ProducesMarkdown] diff --git a/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml b/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml index 60ef16a..b5bade9 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml @@ -19,4 +19,7 @@ sections: - DotNetAstModel_RootNamespaces_ContainsFixtureNamespace - DotNetAstModel_Options_ReturnsOptionsPassedAtConstruction - DotNetAstModel_Assembly_ReturnsLoadedAssembly - - DotNetAstModel_Resolver_IsNotNull + - DotNetAstModel_Resolver_AfterParse_IsNotNull + - DotNetAstModel_XmlDocs_AfterParse_IsNotNull + - DotNetAstModel_NamespaceDescriptions_AfterParse_IsNotNull + - DotNetAstModel_Collections_ExposeReadOnlyInterfaces diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml index 69b0d57..7d9cea6 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml @@ -5,16 +5,106 @@ sections: sections: - title: DotNetEmitterGradualDisclosure Requirements requirements: - - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-ProduceMultiFileTree - title: DotNetEmitterGradualDisclosure shall produce a multi-file gradual-disclosure - Markdown tree with one file per namespace, type, and member. + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitApiIndexPage + title: DotNetEmitterGradualDisclosure shall emit an api.md index page listing all + namespaces with type counts. justification: | - Gradual-disclosure output allows AI agents and human readers to navigate - documentation hierarchically, reading only as much as the task requires. - A separate file per concept ensures each page is independently addressable - and can be read without consuming unrelated content. + The api.md entrypoint gives AI agents and human readers a complete navigation + map in a single read. Listing all namespaces with type counts provides scope + signal without requiring further page traversal. tests: - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesApiIndexPage - DotNetEmitterGradualDisclosure_Emit_ValidModel_ApiIndexContainsAssemblyNameHeading + - DotNetGenerator_OutputFiles_FollowNamingConvention + - DotNetGenerator_Generate_ApiMd_ListsAllNamespacesWithTypeCount + + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitNamespacePages + title: DotNetEmitterGradualDisclosure shall emit a separate Markdown page for + each namespace in the assembly. + justification: | + Separate namespace pages allow AI agents and readers to navigate to a + namespace summary and discover all types within it without reading the + entire assembly index. + tests: - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesNamespacePage + - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesChildNamespacePage + + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitTypePages + title: DotNetEmitterGradualDisclosure shall emit a separate Markdown page for + each visible type in the assembly. + justification: | + Separate type pages allow gradual disclosure: an AI can read a namespace page + to discover types, then open individual type pages on demand, consuming only + as much context as required. + tests: - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesTypePage + + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitMemberDetailPages + title: DotNetEmitterGradualDisclosure shall emit a dedicated detail page for each + visible member of every type in gradual-disclosure output. + justification: | + Per-member pages enable AI agents to navigate directly to + {Namespace}/{TypeName}/{MemberName}.md without reading unrelated members, + keeping context consumption proportional to the task. + tests: + - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMemberDetailPage + - DotNetGenerator_AllMembers_GetSeparateFiles + + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitCombinedPageForCaseCollisions + title: DotNetEmitterGradualDisclosure shall emit a single combined page for members + whose sanitized file names collide on a case-insensitive filesystem. + justification: | + On case-insensitive filesystems (Windows, macOS), two members whose sanitized + names differ only in case would produce colliding filenames. Combining them + onto one page prevents data loss and maintains predictable link targets. + tests: + - DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesCombinedPage + - DotNetGenerator_Generate_CaseCollisionClass_CreatesCombinedPage + - DotNetGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage + - DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers + + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitMethodOverloadPage + title: DotNetEmitterGradualDisclosure shall emit a single consolidated overload + page when a type declares multiple overloads of the same method name. + justification: | + When a type declares multiple overloads of the same method name, combining them + onto one page avoids redundant file proliferation and keeps the type page's + member list concise. Each overload is distinguished by an H2 heading showing + its full parameter signature. + tests: + - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMethodOverloadPage + + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitOperatorsPage + title: DotNetEmitterGradualDisclosure shall emit a dedicated operators.md page + for types that declare operator overloads. + justification: | + Grouping all operator overloads for a type onto a single operators.md page + keeps the type page uncluttered and provides a predictable navigation target + for readers interested in the operator API surface of a type. + tests: + - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesOperatorsPage + - DotNetGenerator_Generate_TypeWithOperators_CreatesOperatorsPage + - DotNetGenerator_Generate_TypeWithOperators_TypePageHasOperatorsSection + + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitNestedTypePages + title: DotNetEmitterGradualDisclosure shall recursively emit dedicated pages + for nested types under their containing type's folder. + justification: | + C# types commonly nest supporting types within their declaring type. Emitting + a dedicated page per nested type under the containing type's folder mirrors + the C# type hierarchy in the file system and makes nested types independently + addressable. + tests: + - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesNestedTypePage + - DotNetGenerator_Generate_NestedClass_ListedOnOuterClassPage + - DotNetGenerator_Generate_NestedClass_CreatesNestedClassPage + - DotNetGenerator_Generate_NestedClass_PageContainsSummary + + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitOperatorsSummaryDocumentation + title: DotNetEmitterGradualDisclosure shall include XML summary documentation for + each operator on the operators page. + justification: | + Including XML summaries makes the operators page self-contained for readers + who only need the operator API without navigating to other pages. + tests: + - DotNetGenerator_Generate_TypeWithOperators_OperatorsPageContainsSummaries diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml index 61185d7..fdd5b34 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml @@ -5,16 +5,95 @@ sections: sections: - title: DotNetEmitterSingleFile Requirements requirements: - - id: ApiMarkDotNet-DotNetEmitterSingleFile-ProduceSingleApiMd - title: DotNetEmitterSingleFile shall emit all API documentation into a - single api.md file using heading levels offset by HeadingDepth. + - id: ApiMarkDotNet-DotNetEmitterSingleFile-EmitAllApiIntoSingleFile + title: DotNetEmitterSingleFile shall emit all assembly API documentation into + a single api.md file. justification: | - Single-file output provides a compact, linear reference that is easier - to consume in contexts where a hierarchical file tree is unwanted, such - as embedding documentation in a pull request or a chatbot context window. + Single-file output provides a compact, linear reference for contexts where a + hierarchical file tree is unwanted, such as embedding documentation in a pull + request, PDF compilation, or a chatbot context window. tests: - DotNetEmitterSingleFile_Emit_ValidModel_CreatesExactlyOneWriter - DotNetEmitterSingleFile_Emit_ValidModel_CreatesApiFileOnly - DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsAssemblyHeading - DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsNamespaceHeading - DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsTypeHeading + - DotNetGenerator_Generate_SingleFileOutput_WritesSingleApiMarkdown + - DotNetGenerator_SingleFile_MethodWithExample_EmitsCodeBlock + + - id: ApiMarkDotNet-DotNetEmitterSingleFile-OffsetHeadingsByHeadingDepth + title: DotNetEmitterSingleFile shall offset all heading levels in the output + by the HeadingDepth value specified in EmitConfig. + justification: | + HeadingDepth allows the generated content to be embedded into a larger + document without conflicting with the host document's heading hierarchy. + A HeadingDepth of 1 produces H1/H2/H3; a value of 2 produces H2/H3/H4. + tests: + - DotNetEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings + + - id: ApiMarkDotNet-DotNetEmitterSingleFile-EmitAssemblyDescription + title: DotNetEmitterSingleFile shall emit the AssemblyDescriptionAttribute value + as a paragraph following the assembly-level heading. + justification: | + The assembly description provides high-level context for the documented library + and is immediately discoverable in single-file output without navigation. + tests: + - DotNetEmitterSingleFile_Emit_AssemblyWithDescription_EmitsDescriptionParagraph + + - id: ApiMarkDotNet-DotNetEmitterSingleFile-EmitNamespaceDocSummary + title: DotNetEmitterSingleFile shall emit the NamespaceDoc XML summary as a + paragraph following the namespace heading. + justification: | + Namespace descriptions authored via the NamespaceDoc convention must appear in + single-file output so readers have namespace context without navigating separate + pages. + tests: + - DotNetEmitterSingleFile_Emit_NamespaceWithDoc_EmitsNamespaceSummary + + - id: ApiMarkDotNet-DotNetEmitterSingleFile-EmitCompactMemberBulletList + title: DotNetEmitterSingleFile shall emit a compact bullet list of member names + and summaries before the per-member heading sections within each type section. + justification: | + The bullet list provides a quick overview of all members before diving into the + detail sections, reducing reading time for large types. + tests: + - DotNetEmitterSingleFile_Emit_TypeWithMembers_EmitsBulletListBeforeMemberHeadings + + - id: ApiMarkDotNet-DotNetEmitterSingleFile-EmitMembersConstructorsFirst + title: DotNetEmitterSingleFile shall emit constructor members before all other + members, with remaining members ordered alphabetically. + justification: | + Constructors are the primary entry point for using a type; placing them first + matches the reader's natural expectation when learning an API. + tests: + - DotNetEmitterSingleFile_Emit_TypeWithConstructorAndMethods_ConstructorAppearsFirst + + - id: ApiMarkDotNet-DotNetEmitterSingleFile-SkipMemberListForDelegates + title: DotNetEmitterSingleFile shall omit the member listing section for delegate + types. + justification: | + Delegate types expose compiler-generated members (Invoke, BeginInvoke, EndInvoke) + that are not meaningful API content; omitting them keeps the delegate section + focused on signature and description. + tests: + - DotNetEmitterSingleFile_Emit_DelegateType_NoMemberSectionsEmitted + + - id: ApiMarkDotNet-DotNetEmitterSingleFile-EmitNestedTypeParentNotice + title: DotNetEmitterSingleFile shall emit a parent-context notice paragraph for + nested types indicating their containing type. + justification: | + In single-file output there is no folder hierarchy to convey nesting + relationships; an explicit notice paragraph makes the containing type + relationship discoverable. + tests: + - DotNetEmitterSingleFile_Emit_NestedType_EmitsParentNotice + + - id: ApiMarkDotNet-DotNetEmitterSingleFile-EmitPlainTextParameterTypes + title: DotNetEmitterSingleFile shall render parameter type cells as plain text + rather than Markdown links. + justification: | + Relative file links are meaningless inside a single document; plain-text type + names keep the output valid in any Markdown renderer without broken link + references. + tests: + - DotNetEmitterSingleFile_Emit_MethodWithParameter_TypeCellIsPlainText diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter.yaml index ef38411..f443a67 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter.yaml @@ -15,8 +15,89 @@ sections: DotNetEmitterSingleFile so that the format-selection logic is concentrated in one place. tests: - - DotNetEmitter_Emit_NullFactory_ThrowsArgumentNullException - DotNetEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles - DotNetEmitter_Emit_SingleFileFormat_ProducesSingleApiFile + - id: ApiMarkDotNet-DotNetEmitter-RejectNullArguments + title: DotNetEmitter.Emit shall throw ArgumentNullException when factory, config, + or context is null. + justification: | + Null arguments at the Emit entry point would produce unpredictable failures + during generation; rejecting them immediately gives callers precise, + actionable failures. + tests: + - DotNetEmitter_Emit_NullFactory_ThrowsArgumentNullException + - DotNetEmitter_Emit_NullConfig_ThrowsArgumentNullException + - DotNetEmitter_Emit_NullContext_ThrowsArgumentNullException + - id: ApiMarkDotNet-DotNetEmitter-ComputeNamespaceFolderPath + title: DotNetEmitter shall compute the file-system folder path for a namespace, + treating root namespaces as atomic path segments and child namespaces as + slash-separated paths. + justification: | + Both sub-emitters rely on a consistent folder path convention so that the + generated Markdown tree is navigable and predictable. Centralizing the path + computation in a single static method ensures that gradual-disclosure and + single-file emitters agree on folder structure. + tests: - DotNetEmitter_GetNamespaceFolderPath_RootNamespace_ReturnsDottedName - DotNetEmitter_GetNamespaceFolderPath_ChildNamespace_ReturnsSlashSeparated + - DotNetEmitter_GetNamespaceFolderPath_UnknownNamespace_ReturnsFullName + - id: ApiMarkDotNet-DotNetEmitter-ShowDirectInheritanceInTypeSignature + title: DotNetEmitter shall include the direct base class and directly declared + interfaces in the type signature, omitting well-known implicit bases such as + System.Object, System.ValueType, System.Enum, and System.MulticastDelegate. + justification: | + The direct inheritance list tells AI agents the contract a type satisfies + without requiring them to navigate to the source file. Suppressing well-known + implicit bases avoids noise that adds no information. + tests: + - DotNetGenerator_Generate_SampleImplementation_TypeSignatureShowsInterface + - DotNetGenerator_Generate_EnumTypeSignature_HasNoBaseClass + - id: ApiMarkDotNet-DotNetEmitter-EmitOperatorsPageWithSymbolHeadings + title: DotNetEmitter shall use C# operator symbols as headings on the operators page. + justification: | + C# operator symbols (operator +, operator ==, implicit/explicit) are the form + developers expect to read, so each heading must render the operator in its + idiomatic symbolic form rather than as a method name. + tests: + - DotNetGenerator_Generate_TypeWithOperators_OperatorsPageUsesSymbolHeadings + - id: ApiMarkDotNet-DotNetEmitter-EmitConversionOperators + title: DotNetEmitter shall document conversion operators on the operators page + and include their summaries. + justification: | + Conversion operators are part of the public API surface and must be included + on the operators page with their XML summary so readers understand the + conversion intent without navigating elsewhere. + tests: + - DotNetGenerator_Generate_TypeWithConversionOperators_OperatorsPageContainsSummaries + - DotNetGenerator_Generate_ConversionOperatorReturningNestedType_OperatorsPageContainsSummary + - id: ApiMarkDotNet-DotNetEmitter-EmitConversionOperatorsWithConversionSyntax + title: DotNetEmitter shall use conversion syntax headings for conversion operators + on the operators page. + justification: | + Implicit and explicit conversion operators have distinct syntax from arithmetic + operators. Rendering them with conversion syntax (e.g., "implicit operator + TargetType(SourceType)") lets readers identify the source and target types at + a glance. + tests: + - DotNetGenerator_Generate_TypeWithConversionOperators_OperatorsPageUsesConversionSyntax + - id: ApiMarkDotNet-DotNetEmitter-RenderClassModifiersInTypeSignature + title: DotNetEmitter shall include the abstract, sealed, or static modifier + in the type signature for classes that declare that modifier. + justification: | + Generated type signatures must match the C# declaration so that readers + can immediately identify abstract, sealed, and static classes without + consulting the source file. + tests: + - DotNetEmitter_BuildTypeSignature_AbstractClass_ContainsAbstractModifier + - DotNetEmitter_BuildTypeSignature_SealedClass_ContainsSealedModifier + - DotNetEmitter_BuildTypeSignature_StaticClass_ContainsStaticModifier + + - id: ApiMarkDotNet-DotNetEmitter-RenderInitOnlyPropertyAccessor + title: DotNetEmitter shall emit init; rather than set; in property signatures + for C# 9+ init-only setters. + justification: | + Init-only setters are semantically distinct from regular setters — they can + only be assigned during object initialization. Rendering them as set; would + mislead readers into believing the property is mutable after construction. + tests: + - DotNetEmitter_BuildPropertyAccessors_InitOnlySetter_EmitsInit diff --git a/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml b/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml index eb88aa0..ff15acb 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml @@ -44,6 +44,7 @@ sections: a public API reference to broader internal documentation for development. tests: - DotNetGenerator_Visibility_PublicPublicAndProtectedAll_FilterExpectedApis + - DotNetGenerator_Generate_AllVisibility_IncludesPrivateMembers - id: ApiMarkDotNet-DotNetGenerator-IncludeObsoleteApisWhenRequested title: DotNetGenerator shall control obsolete API inclusion via the IncludeObsolete flag, including them when enabled and omitting them when disabled. @@ -51,125 +52,75 @@ sections: Teams sometimes need complete historical reference output and sometimes need only the actively supported API surface, so obsolete items must be controllable. tests: [DotNetGenerator_IncludeObsolete_Toggle_ControlsObsoleteOutput] - - id: ApiMarkDotNet-DotNetGenerator-EmitDetailPageForEveryMember - title: DotNetGenerator shall emit a dedicated detail page for every visible - member. + - id: ApiMarkDotNet-DotNetGenerator-RecognizeNamespaceDocCarrierClass + title: DotNetGenerator shall recognize internal static NamespaceDoc classes as + namespace documentation carriers. justification: | - All members always receive their own dedicated file so that navigation is - fully deterministic. An AI or tool can always resolve - {Namespace}/{TypeName}/{MemberName}.md without inspecting member content - or shape. Exception: members whose sanitized names collide on a - case-insensitive filesystem are combined onto a single shared page (see - ApiMarkDotNet-DotNetGenerator-EmitCombinedMemberPageForCaseInsensitiveCollisions). - tests: [DotNetGenerator_AllMembers_GetSeparateFiles] - - id: ApiMarkDotNet-DotNetGenerator-EmitCombinedMemberPageForCaseInsensitiveCollisions - title: DotNetGenerator shall emit a combined member page for case-insensitive - filename collisions. - justification: | - On case-insensitive filesystems (Windows, macOS), two members whose sanitized - names differ only in case (e.g. a field 'name' and a property 'Name') would - produce colliding filenames. To avoid data loss and maintain predictable link - targets, such members are combined onto a single page named after the - lower-invariant of the shared key, with each member distinguished by an H2 - heading showing its exact name and kind label. + The NamespaceDoc convention is the standard way to attach documentation to a + namespace in C#. ApiMark must identify these carrier classes so their XML + summary can be used as the namespace description. tests: - - DotNetGenerator_Generate_CaseCollisionClass_CreatesCombinedPage - - DotNetGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage - - DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers - - id: ApiMarkDotNet-DotNetGenerator-ProduceConsistentOutputFileStructure - title: DotNetGenerator shall produce output files following the established - naming convention. - justification: | - Consistent file and folder naming allows navigation between the entrypoint, - namespace summaries, type pages, and member detail pages without relying on - a separate path-resolution helper. - tests: [DotNetGenerator_OutputFiles_FollowNamingConvention] - - id: ApiMarkDotNet-DotNetGenerator-ApiMdListsAllNamespacesWithTypeCount - title: DotNetGenerator shall list all namespaces in api.md with a direct - type count column. + - DotNetGenerator_Generate_NamespaceDocClass_ExcludedFromTypeListing + - DotNetGenerator_NamespacePage_NamespaceDocSummary_AppearsAsNamespaceDescription + - id: ApiMarkDotNet-DotNetGenerator-ExcludeNamespaceDocCarrierFromTypeListing + title: DotNetGenerator shall exclude NamespaceDoc carrier classes from generated + type listings. justification: | - Listing every namespace (root and child) in api.md gives AI agents a complete - navigation map in a single read, avoiding an extra round-trip through parent - namespace pages to discover children. The type count column provides scope - signal so an AI can calibrate how deeply to explore each namespace without - opening it first. - tests: [DotNetGenerator_Generate_ApiMd_ListsAllNamespacesWithTypeCount] - - id: ApiMarkDotNet-DotNetGenerator-ShowDirectInheritanceInTypeSignature - title: >- - DotNetGenerator shall include the direct base class and directly declared - interfaces in the type signature, omitting well-known implicit bases - such as - System.Object, System.ValueType, System.Enum, and System.MulticastDelegate. + Including the carrier class in type listings would pollute the API surface with + an implementation detail not intended for public consumption. + tests: + - DotNetGenerator_Generate_NamespaceDocClass_ExcludedFromTypeListing + - id: ApiMarkDotNet-DotNetGenerator-UseNamespaceDocSummaryAsNamespaceDescription + title: DotNetGenerator shall use the XML summary of a NamespaceDoc class as the + description for its containing namespace. justification: | - The direct inheritance list tells AI agents the contract a type satisfies and - the class it extends without requiring them to navigate to the source file. - Suppressing well-known implicit bases avoids noise that adds no information. + Developer-authored namespace descriptions belong in the published API reference. + The NamespaceDoc carrier class is the standard mechanism for providing this + description. tests: - - DotNetGenerator_Generate_SampleImplementation_TypeSignatureShowsInterface - - DotNetGenerator_Generate_EnumTypeSignature_HasNoBaseClass - - id: ApiMarkDotNet-DotNetGenerator-EmitIntraDocLinksInTableCells - title: DotNetGenerator shall emit Markdown links in type and parameter - type table cells when the referenced type is documented in the same - assembly. + - DotNetGenerator_NamespacePage_NamespaceDocClass_ExcludedFromTypeListing + - DotNetGenerator_NamespacePage_NamespaceDocSummary_AppearsAsNamespaceDescription + - id: ApiMarkDotNet-DotNetGenerator-RejectMissingAssemblyPath + title: DotNetGenerator shall throw FileNotFoundException when the configured + assembly path does not exist. justification: | - AI agents and human readers navigating API documentation benefit from - clickable links that jump directly to the referenced type page rather - than requiring a separate search. Links are only emitted in table cells, - not inside fenced code blocks, because Markdown links do not render - inside fences. + Fail-fast on a missing assembly avoids a confusing downstream error and gives + the caller an actionable exception with the missing path. tests: - - DotNetGenerator_Generate_IntraAssemblyReturnType_EmitsMarkdownLinkInReturnsCell - - id: ApiMarkDotNet-DotNetGenerator-EmitExternalTypesSection - title: DotNetGenerator shall emit an "External Types" section at the bottom - of each generated page that references at least one non-System external - type. + - DotNetGenerator_Parse_MissingAssemblyPath_ThrowsFileNotFoundException + - id: ApiMarkDotNet-DotNetGenerator-RejectMissingXmlDocPath + title: DotNetGenerator shall throw FileNotFoundException when the configured + XML documentation path does not exist. justification: | - Readers using an external library type documented on a page need to know - which package or namespace to import. Collecting all external type - references per page and listing them alphabetically provides this context - without cluttering individual type cells with fully-qualified names. + Fail-fast on a missing XML documentation file avoids a confusing downstream + error and gives the caller an actionable exception with the missing path. tests: - - DotNetGenerator_Generate_ExternalNonSystemParameterType_EmitsExternalTypesSection - - id: ApiMarkDotNet-DotNetGenerator-DocumentOperatorOverloads - title: >- - DotNetGenerator shall emit operator overload methods for a type on its - dedicated operators.md page, using C# operator symbols as headings and - including conversion operator documentation with summary and parameters. + - DotNetGenerator_Generate_XmlDocMissing_ThrowsFileNotFoundException + - id: ApiMarkDotNet-DotNetGenerator-RejectNullOptions + title: DotNetGenerator constructor shall throw ArgumentNullException when options + is null. justification: | - C# types frequently expose arithmetic, comparison, and conversion operators - as part of their public API. Grouping all operators for a type onto a single - operators.md page with C# symbol headings (operator +, operator -) makes the - operator API surface discoverable and navigable without file-name collisions. + Null options would produce a NullReferenceException at an unpredictable point + during generation. Rejecting null at construction gives the caller a precise, + actionable failure. tests: - - DotNetGenerator_Generate_TypeWithOperators_CreatesOperatorsPage - - DotNetGenerator_Generate_TypeWithOperators_TypePageHasOperatorsSection - - DotNetGenerator_Generate_TypeWithOperators_OperatorsPageContainsSummaries - - DotNetGenerator_Generate_TypeWithOperators_OperatorsPageUsesSymbolHeadings - - DotNetGenerator_Generate_TypeWithConversionOperators_OperatorsPageContainsSummaries - - DotNetGenerator_Generate_TypeWithConversionOperators_OperatorsPageUsesConversionSyntax - - DotNetGenerator_Generate_ConversionOperatorReturningNestedType_OperatorsPageContainsSummary - - id: ApiMarkDotNet-DotNetGenerator-DocumentNestedTypes - title: >- - DotNetGenerator shall document public nested types within their containing - type's page and - generate a dedicated page for each nested type under the containing - type's folder. + - DotNetGenerator_Constructor_NullOptions_ThrowsArgumentNullException + - id: ApiMarkDotNet-DotNetGenerator-RejectNullContext + title: DotNetGenerator.Parse shall throw ArgumentNullException when context is null. justification: | - C# types commonly nest supporting types (builders, enumerators, result types, configuration - records) within their declaring type as part of the public API. Omitting nested types from - the generated documentation silently drops documented API surface that consumers depend on. - Each nested type receives a dedicated page at {NamespacePath}/{OuterTypeName}/{NestedTypeName}.md - so the documentation hierarchy mirrors the C# type hierarchy. + A null context would produce a NullReferenceException at the first + context.WriteLine call. Rejecting null eagerly gives the caller a precise, + actionable failure. tests: - - DotNetGenerator_Generate_NestedClass_CreatesNestedClassPage - - DotNetGenerator_Generate_NestedClass_ListedOnOuterClassPage - - DotNetGenerator_Generate_NestedClass_PageContainsSummary - - id: ApiMarkDotNet-DotNetGenerator-EmitSingleFileOutput - title: DotNetGenerator shall emit a single api.md file when OutputFormat.SingleFile - is selected. + - DotNetGenerator_Parse_NullContext_ThrowsArgumentNullException + - id: ApiMarkDotNet-DotNetGenerator-BuildInheritanceChainForInheritDoc + title: DotNetGenerator shall build and supply an inheritance chain map from + assembly metadata to XmlDocReader to enable bare inheritdoc resolution. justification: | - Single-file output is required for consumers who want a compact, linear reference - instead of the gradual-disclosure folder tree. + Bare inheritdoc elements (without a cref attribute) require an inheritance + chain map to resolve the correct base documentation. Building this map at + parse time from Mono.Cecil metadata allows XmlDocReader to resolve inherited + documentation without accessing the assembly again during emit. tests: - - DotNetGenerator_Generate_SingleFileOutput_WritesSingleApiMarkdown - - DotNetGenerator_SingleFile_MethodWithExample_EmitsCodeBlock + - DotNetGenerator_Generate_SampleImplementationNameMemberPage_UsesInheritedSummary + - DotNetGenerator_Generate_SampleImplementationExecuteMemberPage_UsesInheritedSummaryAndParamDescription diff --git a/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml b/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml index 4135dee..cb99228 100644 --- a/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml +++ b/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml @@ -5,20 +5,75 @@ sections: sections: - title: TypeLinkResolver Requirements requirements: - - id: ApiMarkDotNet-TypeLinkResolver-ResolveTypeReferencesToMarkdownLinks - title: TypeLinkResolver shall resolve Mono.Cecil type references to Markdown - link text, emitting intra-assembly links or plain text for external - and primitive types. + - id: ApiMarkDotNet-TypeLinkResolver-ResolveIntraAssemblyTypesToMarkdownLinks + title: TypeLinkResolver shall resolve intra-assembly type references to relative + Markdown links when generateLinks is true, and plain text when false. justification: | Intra-document cross-references improve navigability for both AI agents - and human readers. TypeLinkResolver centralizes the logic for deciding - whether a type reference produces a clickable link (intra-assembly), - a plain alias (primitive or System type), or tracked plain text (external - non-System type), keeping emitter code free of resolution logic. + and human readers. Controlling link generation via generateLinks allows + the single-file emitter to suppress file-relative links that would be + invalid in a single-file context. tests: - TypeLinkResolver_Linkify_NullTypeRef_ReturnsEmptyString - - TypeLinkResolver_Linkify_Int32_ReturnsCSharpAlias - - TypeLinkResolver_Linkify_StringType_ReturnsCSharpAlias - TypeLinkResolver_Linkify_GenerateLinksTrue_IntraAssemblyType_ReturnsMarkdownLink - TypeLinkResolver_Linkify_GenerateLinksFalse_IntraAssemblyType_ReturnsPlainText - TypeLinkResolver_Linkify_NullableGenericParameter_AppendsQuestionMark + - DotNetGenerator_Generate_IntraAssemblyReturnType_EmitsMarkdownLinkInReturnsCell + - id: ApiMarkDotNet-TypeLinkResolver-ResolvePrimitiveAndSystemTypesToPlainText + title: TypeLinkResolver shall resolve C# primitive types and System-namespace + types to their plain simplified names without tracking them as external + dependencies. + justification: | + Primitive and System types are universally known and do not require + readers to install separate packages, so they must be rendered as + plain text and excluded from the External Types section to keep that + section focused on third-party dependencies. + tests: + - TypeLinkResolver_Linkify_Int32_ReturnsCSharpAlias + - TypeLinkResolver_Linkify_StringType_ReturnsCSharpAlias + - TypeLinkResolver_Linkify_SystemNamespaceExternalType_ReturnsPlainTextAndDoesNotTrack + - id: ApiMarkDotNet-TypeLinkResolver-TrackNonSystemExternalTypesForExternalTypesSection + title: TypeLinkResolver shall resolve non-System external type references to + plain text and add them to the caller-supplied external type set for later + emission in the External Types section. + justification: | + Non-System external types originate from third-party packages that readers + must install separately. Accumulating them per output file and emitting an + External Types section alerts readers to additional dependencies without + cluttering the main member table with redundant package information. + tests: + - TypeLinkResolver_Linkify_ExternalNonSystemType_ReturnsPlainNameAndTracksExternalType + - DotNetGenerator_Generate_ExternalNonSystemParameterType_EmitsExternalTypesSection + + - id: ApiMarkDotNet-TypeLinkResolver-RenderArrayTypesWithSuffix + title: TypeLinkResolver shall render array types by appending the array rank suffix + to the element type name. + justification: | + C# array types such as string[] and int[,] are common in API signatures. Rendering + them with the correct suffix ensures the generated Markdown matches the C# syntax + readers expect, making member signatures self-explanatory without requiring a + separate type-lookup step. + tests: + - TypeLinkResolver_Linkify_ArrayType_AppendsArraySuffix + - TypeLinkResolver_Linkify_MultiDimensionalArrayType_AppendsRankSuffix + + - id: ApiMarkDotNet-TypeLinkResolver-RenderGenericTypesWithTypeArguments + title: TypeLinkResolver shall render generic container types by appending the resolved + type argument list in angle-bracket notation. + justification: | + Generic types such as List and Dictionary appear frequently + in .NET API signatures. Rendering the full generic form — including resolved type + arguments — gives AI agents and human readers the complete type contract in a + single table cell read. + tests: + - TypeLinkResolver_Linkify_GenericType_RendersTypeArguments + + - id: ApiMarkDotNet-TypeLinkResolver-UnwrapNullableValueTypes + title: TypeLinkResolver shall unwrap Nullable generic instances and render + the inner type with a ? suffix. + justification: | + C# value types such as int? and DateTime? are Nullable generic instances + at the CLR level. Rendering them as "int?" rather than "Nullable" matches + the C# syntax that developers read and write, keeping member signatures idiomatic. + tests: + - TypeLinkResolver_Linkify_NullableValueType_ReturnsInnerAliasWithQuestionMark diff --git a/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml b/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml index 66b89fb..bc0a956 100644 --- a/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml +++ b/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml @@ -11,19 +11,19 @@ sections: justification: | Readers expect C# signatures in documentation to use familiar language aliases instead of verbose CLR type names. - tests: [TypeNameSimplifier_Primitives_RenderLanguageAliases] + tests: [TypeNameSimplifier_Simplify_Primitives_RenderLanguageAliases] - id: ApiMarkDotNet-TypeNameSimplifier-SimplifyArrayTypes title: TypeNameSimplifier shall render array types using C# bracket notation. justification: | Array types appear frequently in .NET APIs and must use the idiomatic T[] form rather than the CLR array type representation. - tests: [TypeNameSimplifier_ArrayType_ReturnsBracketNotation] + tests: [TypeNameSimplifier_Simplify_ArrayType_ReturnsBracketNotation] - id: ApiMarkDotNet-TypeNameSimplifier-RenderNullableValueTypesWithQuestionMark title: TypeNameSimplifier shall render nullable value types in T? form. justification: | Documentation should present nullable value types using standard C# syntax rather than the more verbose Nullable representation. - tests: [TypeNameSimplifier_NullableValueTypes_UseQuestionMarkForm] + tests: [TypeNameSimplifier_Simplify_NullableValueTypes_UseQuestionMarkForm] - id: ApiMarkDotNet-TypeNameSimplifier-SimplifyWellKnownNamespaces title: TypeNameSimplifier shall use unqualified names for types in well-known namespaces. @@ -31,7 +31,7 @@ sections: Types from System.Collections.Generic and System.Threading.Tasks appear frequently in public APIs and are universally recognized without their namespace prefix, keeping signatures compact. - tests: [TypeNameSimplifier_WellKnownNamespaceTypes_RenderWithoutNamespace] + tests: [TypeNameSimplifier_Simplify_WellKnownNamespaceTypes_RenderWithoutNamespace] - id: ApiMarkDotNet-TypeNameSimplifier-SimplifyContextNamespaceTypes title: TypeNameSimplifier shall strip the context namespace prefix from types in the same or nested namespace. @@ -39,14 +39,15 @@ sections: When documenting a type, cross-references to other types in the same namespace are easier to read without the repeated namespace prefix. tests: - - TypeNameSimplifier_ContextNamespaceTypes_RenderWithoutSharedPrefix + - TypeNameSimplifier_Simplify_ContextNamespaceTypes_RenderWithoutSharedPrefix + - TypeNameSimplifier_Simplify_ContextNamespaceTypes_NestedNamespace_StripsSharedPrefix - id: ApiMarkDotNet-TypeNameSimplifier-SimplifyGenericArgumentsRecursively title: TypeNameSimplifier shall apply simplification recursively to generic type arguments. justification: | Complex APIs frequently nest generic types, so simplification has to work at every level to prevent signatures from becoming unreadable. - tests: [TypeNameSimplifier_GenericArguments_AreSimplifiedRecursively] + tests: [TypeNameSimplifier_Simplify_GenericArguments_AreSimplifiedRecursively] - id: ApiMarkDotNet-TypeNameSimplifier-AppendNullableReferenceSuffix title: TypeNameSimplifier shall append ? to nullable reference types. justification: | diff --git a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml index d04912d..b13070f 100644 --- a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml +++ b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml @@ -6,15 +6,15 @@ sections: - title: XmlDocReader Requirements requirements: - id: ApiMarkDotNet-XmlDocReader-ParseAndIndexXmlDocumentation - title: XmlDocReader shall parse a .NET XML documentation file and provide - O(1) per-member lookups for summary, remarks, parameters, returns, exceptions, - and example content. + title: XmlDocReader shall read assembly documentation data from a .NET XML + documentation file. justification: | - Assembly documentation comes from compiler-produced XML files that can - contain thousands of member entries. Indexing on construction ensures - each documentation lookup during emit is O(1) rather than a linear scan, - keeping generation time proportional to the number of members documented - rather than the square of that count. + Assembly documentation comes from compiler-produced XML files. XmlDocReader + parses and indexes all documentation metadata — including summary, remarks, + parameters, returns, exceptions, and examples — on construction, ensuring + each per-member lookup during emit uses direct index access rather than a + linear scan and keeping generation time proportional to the number of + documented members. tests: - XmlDocReader_Constructor_FileDoesNotExist_ThrowsFileNotFoundException - XmlDocReader_GetSummary_MemberPresent_ReturnsTrimmedText @@ -34,7 +34,8 @@ sections: - XmlDocReader_GetReturns_MemberAbsent_ReturnsNull - XmlDocReader_GetExample_MemberAbsent_ReturnsNull - id: ApiMarkDotNet-XmlDocReader-InheritDoc - title: Inherit XML Documentation from Base Members + title: XmlDocReader shall resolve `` references and inherit + XML documentation from base members and interface members. justification: > C# XML documentation supports `` to inherit documentation from base class members or interface members. ApiMark must resolve these diff --git a/docs/verification/api-mark-dot-net.md b/docs/verification/api-mark-dot-net.md index 6762a7b..4f43012 100644 --- a/docs/verification/api-mark-dot-net.md +++ b/docs/verification/api-mark-dot-net.md @@ -21,7 +21,7 @@ service, network dependency, or machine-specific configuration is required. - All ApiMark.DotNet tests pass with zero failures. - The generator discovers namespaces, types, and members from representative fixture assemblies. - Type names and member signatures are simplified into the expected C#-friendly display form. -- Every visible member receives its own dedicated detail page and is linked from the type page. +- Every visible member receives a dedicated detail page or is combined onto a shared collision page, and is linked from the type page. - Visibility filtering excludes members outside the selected audience. - Obsolete member filtering correctly excludes or includes deprecated APIs based on the IncludeObsolete option. diff --git a/docs/verification/api-mark-dot-net/dot-net-ast-model.md b/docs/verification/api-mark-dot-net/dot-net-ast-model.md index 6b12fb7..99bcb2e 100644 --- a/docs/verification/api-mark-dot-net/dot-net-ast-model.md +++ b/docs/verification/api-mark-dot-net/dot-net-ast-model.md @@ -23,6 +23,9 @@ No external service, network dependency, or privileged configuration is needed. - `Options` returns the same `DotNetGeneratorOptions` instance passed to the constructor. - `Assembly` is non-null and has the expected name after parsing. - `Resolver` is non-null after parsing. +- `XmlDocs` is non-null after parsing. +- `NamespaceDescriptions` is non-null after parsing. +- All collection-type properties (`AllNamespaces`, `ByNamespace`, `RootNamespaces`) expose read-only interfaces. ### Test Scenarios @@ -57,4 +60,23 @@ member iteration during emission. This scenario is tested by **Resolver property is non-null**: Verifies that `DotNetAstModel.Resolver` is non-null after parsing, confirming that the type-reference resolver used during link generation is initialized as part of the model. This scenario is tested by -`DotNetAstModel_Resolver_IsNotNull`. +`DotNetAstModel_Resolver_AfterParse_IsNotNull`. + +**XmlDocs property is non-null**: Verifies that `DotNetAstModel.XmlDocs` is +non-null after construction, confirming that the XML documentation reader is +retained in the model for per-member lookups during emission. This scenario is +tested by `DotNetAstModel_XmlDocs_AfterParse_IsNotNull`. + +**NamespaceDescriptions property is non-null**: Verifies that +`DotNetAstModel.NamespaceDescriptions` is non-null after construction, confirming +that the namespace description map (populated from NamespaceDoc carriers) is +initialized in the model even when no carriers are present. This scenario is +tested by `DotNetAstModel_NamespaceDescriptions_AfterParse_IsNotNull`. + +**All collection-type properties expose read-only interfaces**: Verifies that +`AllNamespaces`, `ByNamespace`, and `RootNamespaces` properties all return +`IReadOnly*` interface types, confirmed by the C# compiler type system — no runtime +test is required for the static guarantee. Supplementary runtime test: +`DotNetAstModel_Collections_ExposeReadOnlyInterfaces` asserts that the runtime +types satisfy the interface constraints by calling `Assert.IsAssignableFrom` on +each collection property. diff --git a/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index 7d93cb9..59e10c3 100644 --- a/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -20,6 +20,12 @@ service or network dependency is needed. - The api index page is created with the expected assembly name heading. - A namespace summary page is created for each namespace in the fixture assembly. - A type page is created for each visible type in each namespace. +- At least one member detail page is created for a visible member of a visible type. +- A single combined page is created for members whose sanitized file names collide on a + case-insensitive filesystem. +- A type with pure method overloads produces a consolidated method overload page. +- A type with operator overloads produces an `operators.md` page. +- A type with a nested type produces a dedicated page under the containing type's folder. ### Test Scenarios @@ -40,3 +46,34 @@ by `DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesNamespacePage`. contains `SampleClass` is created, confirming that per-type pages are emitted for all visible types. This scenario is tested by `DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesTypePage`. + +**Member detail page is created for SampleClass.Reset**: Verifies that a writer +whose key contains both `SampleClass` and `Reset` is created, confirming that +per-member detail pages are emitted for all visible members. This scenario is +tested by `DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMemberDetailPage`. + +**Combined collision page is created for CaseCollisionClass**: Verifies that when +`CaseCollisionClass` has members whose sanitized names differ only in case (`name` +and `Name`), the emitter creates a single combined page keyed by the lower-invariant +name rather than two separate colliding pages. This scenario is tested by +`DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesCombinedPage`. + +**Method overload page is created for overloaded methods**: Verifies that a type +with multiple overloads of the same method name produces a consolidated overload +page rather than separate pages per overload. This scenario is tested by +`DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMethodOverloadPage`. + +**Operators page is created for types with operator overloads**: Verifies that a +type defining operator overloads produces an `operators.md` page under the type +folder. This scenario is tested by +`DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesOperatorsPage`. + +**Nested type page is created under the containing type's folder**: Verifies that +a type containing a nested type produces a dedicated page for that nested type +placed under the containing type's folder path. This scenario is tested by +`DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesNestedTypePage`. + +**Child namespace page is created**: Verifies that a child namespace (such as +`ApiMark.DotNet.Fixtures.Inner`) also produces a dedicated Markdown summary page, +confirming that child namespace enumeration works correctly. This scenario is tested by +`DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesChildNamespacePage`. diff --git a/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md b/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md index 939bb9d..7576940 100644 --- a/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md +++ b/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md @@ -23,6 +23,14 @@ service or network dependency is needed. - The output file contains an assembly-level heading. - The output file contains a namespace-level heading. - The output file contains a type-level heading for the fixture type. +- When `HeadingDepth` is set to a non-default value, all heading levels in the output are offset accordingly. +- When the assembly carries an `AssemblyDescriptionAttribute`, its value is emitted as a paragraph after the assembly-level heading. +- When a namespace has a NamespaceDoc XML summary, that summary is emitted as a paragraph below the namespace heading. +- A compact bullet list of member names and summaries is emitted before the per-member heading sections within each type section. +- Constructor members appear before all other members; remaining members are ordered alphabetically. +- Delegate types do not emit compiler-generated member sections (Invoke, BeginInvoke, EndInvoke). +- Nested types include a parent-context notice paragraph (e.g., "Nested type of `OuterClass`."). +- Parameter type cells in tables contain plain text, not Markdown links. ### Test Scenarios @@ -45,3 +53,48 @@ by `DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsNamespaceHeading`. **Api file contains a type-level heading for SampleClass**: Verifies that the output file includes a heading for `SampleClass`. This scenario is tested by `DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsTypeHeading`. + +**Non-default HeadingDepth offsets all heading levels**: Verifies that when +`HeadingDepth` is configured to a non-default value, the heading levels in the +output are offset accordingly so the document integrates correctly into a larger +compound document. This scenario is tested by +`DotNetEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings`. + +**Assembly description paragraph follows assembly heading**: Verifies that when +the assembly carries an `AssemblyDescriptionAttribute`, its value is emitted as a +paragraph immediately after the assembly-level heading. This scenario is tested by +`DotNetEmitterSingleFile_Emit_AssemblyWithDescription_EmitsDescriptionParagraph`. + +**NamespaceDoc summary follows namespace heading**: Verifies that a namespace +carrying a NamespaceDoc carrier class has its XML summary emitted as a paragraph +below the namespace heading. This scenario is tested by +`DotNetEmitterSingleFile_Emit_NamespaceWithDoc_EmitsNamespaceSummary`. + +**Compact bullet list appears before per-member headings**: Verifies that within +each type section, a compact bullet list paragraph summarizing all members is +emitted before the individual H{depth+3} member heading sections. This scenario +is tested by +`DotNetEmitterSingleFile_Emit_TypeWithMembers_EmitsBulletListBeforeMemberHeadings`. + +**Constructors appear before other members**: Verifies that for a type with both +a constructor and other members, the constructor heading appears before the other +member headings in the output. This scenario is tested by +`DotNetEmitterSingleFile_Emit_TypeWithConstructorAndMethods_ConstructorAppearsFirst`. + +**Delegate types omit compiler-generated member sections**: Verifies that the +single-file emitter does not emit Invoke, BeginInvoke, or EndInvoke member sections +for delegate types, keeping the delegate section focused on signature and description. +This scenario is tested by +`DotNetEmitterSingleFile_Emit_DelegateType_NoMemberSectionsEmitted`. + +**Nested types include a parent-context notice**: Verifies that for a nested type, +a paragraph of the form "Nested type of `OuterType`." is emitted immediately after +the type heading so that readers can identify the containing type relationship in +a flat document. This scenario is tested by +`DotNetEmitterSingleFile_Emit_NestedType_EmitsParentNotice`. + +**Parameter type cells are plain text, not Markdown links**: Verifies that type +cells in parameter tables contain plain simplified type names without Markdown link +syntax (`[Name](path.md)`), since relative file links are meaningless inside a +single document. This scenario is tested by +`DotNetEmitterSingleFile_Emit_MethodWithParameter_TypeCellIsPlainText`. diff --git a/docs/verification/api-mark-dot-net/dot-net-emitter.md b/docs/verification/api-mark-dot-net/dot-net-emitter.md index f71cf0b..ff2c439 100644 --- a/docs/verification/api-mark-dot-net/dot-net-emitter.md +++ b/docs/verification/api-mark-dot-net/dot-net-emitter.md @@ -18,10 +18,23 @@ service or network dependency is needed. - All `DotNetEmitter` tests pass with zero failures. - Passing a null factory to `Emit` throws `ArgumentNullException` before any I/O. +- Passing a null config to `Emit` throws `ArgumentNullException` before any I/O. +- Passing a null context to `Emit` throws `ArgumentNullException` before any I/O. - `OutputFormat.GradualDisclosure` produces more than one Markdown writer. - `OutputFormat.SingleFile` produces exactly one writer keyed `api`. - `GetNamespaceFolderPath` returns the full dotted name for a root namespace and a slash-separated path for a child namespace. +- `GetNamespaceFolderPath` returns the full dotted name when no root namespace matches. +- `BuildTypeSignature` includes the `abstract` modifier for abstract non-sealed classes. +- `BuildTypeSignature` includes the `sealed` modifier for sealed non-abstract classes. +- `BuildTypeSignature` includes the `static` modifier for static classes (abstract and sealed in IL). +- `IsNamespaceDocCarrier` returns `true` for a class named `NamespaceDoc` that is internal and static. +- `IsNamespaceDocCarrier` returns `false` for a regular public class. +- `BuildPropertyAccessors` does not prefix accessors that share the property's declared + accessibility (e.g., a protected property with protected get and set renders as + `protected { get; set; }` without redundant prefixes). +- `BuildPropertyAccessors` emits `init;` for init-only (C# 9+) property setters. +- `ToXmlDocTypeName` converts Cecil generic type names (e.g. `List\`1`) to XML doc member-ID format (e.g.`List{T}`) so XML doc lookups use the correct key format. ### Test Scenarios @@ -30,6 +43,22 @@ service or network dependency is needed. any I/O is attempted. This scenario is tested by `DotNetEmitter_Emit_NullFactory_ThrowsArgumentNullException`. +**Null config throws ArgumentNullException**: Verifies that calling +`DotNetEmitter.Emit` with a null config throws `ArgumentNullException` before +any I/O is attempted. This scenario is tested by +`DotNetEmitter_Emit_NullConfig_ThrowsArgumentNullException`. + +**Null context throws ArgumentNullException**: Verifies that calling +`DotNetEmitter.Emit` with a null context throws `ArgumentNullException` before +any I/O is attempted. This scenario is tested by +`DotNetEmitter_Emit_NullContext_ThrowsArgumentNullException`. + +**ToXmlDocTypeName converts Cecil generic notation**: Verifies that +`DotNetEmitter.ToXmlDocTypeName` converts Cecil-encoded generic instantiations +(using angle brackets) to the XML doc ID encoding (using curly braces), and that +nested-type separators are normalized from `/` to `.`. This scenario is tested by +`DotNetEmitter_ToXmlDocTypeName_ConvertsGenericNotation`. + **GradualDisclosure format produces multiple files**: Verifies that when `OutputFormat.GradualDisclosure` is configured the emitter produces more than one Markdown writer. This scenario is tested by @@ -49,3 +78,49 @@ name as the folder path. This scenario is tested by Verifies that a namespace that is a child of a configured root returns a slash-separated path. This scenario is tested by `DotNetEmitter_GetNamespaceFolderPath_ChildNamespace_ReturnsSlashSeparated`. + +**GetNamespaceFolderPath returns the full name for an unknown namespace**: Verifies +that a namespace that matches no configured root returns its full dotted name as a +safe fallback. This scenario is tested by +`DotNetEmitter_GetNamespaceFolderPath_UnknownNamespace_ReturnsFullName`. + +**Abstract class type signature contains abstract modifier**: Verifies that +`DotNetEmitter.BuildTypeSignature` includes the `abstract` keyword for a class that +is abstract but not sealed, so that readers can see the correct modifier at a glance. +This scenario is tested by +`DotNetEmitter_BuildTypeSignature_AbstractClass_ContainsAbstractModifier`. + +**Sealed class type signature contains sealed modifier**: Verifies that +`DotNetEmitter.BuildTypeSignature` includes the `sealed` keyword for a class that is +sealed but not abstract, so the modifier is visible in generated documentation. This +scenario is tested by +`DotNetEmitter_BuildTypeSignature_SealedClass_ContainsSealedModifier`. + +**Static class type signature contains static modifier**: Verifies that +`DotNetEmitter.BuildTypeSignature` includes the `static` keyword for a static class +(which compiles to abstract+sealed in IL), so the static nature of the class is +accurately reflected in generated documentation. This scenario is tested by +`DotNetEmitter_BuildTypeSignature_StaticClass_ContainsStaticModifier`. + +**IsNamespaceDocCarrier returns true for NamespaceDoc class**: Verifies that +`DotNetEmitter.IsNamespaceDocCarrier` correctly identifies an `internal static class +NamespaceDoc` as a carrier type so it can be excluded from type listings and its +summary can be promoted to the namespace description. This scenario is tested by +`DotNetEmitter_IsNamespaceDocCarrier_NamespaceDocClass_ReturnsTrue`. + +**IsNamespaceDocCarrier returns false for a regular class**: Verifies that +`DotNetEmitter.IsNamespaceDocCarrier` does not falsely classify a regular public +class as a carrier type. This scenario is tested by +`DotNetEmitter_IsNamespaceDocCarrier_RegularClass_ReturnsFalse`. + +**Init-only property accessor renders as `init;`**: Verifies that `BuildPropertyAccessors` emits +`init;` rather than `set;` for properties declared with the C# 9 `init` accessor keyword, so +generated signatures correctly distinguish init-only properties (used by records and immutable types) +from regular settable properties. This scenario is tested by +`DotNetEmitter_BuildPropertyAccessors_InitOnlySetter_EmitsInit`. + +**Shared-accessibility property accessors are not prefixed**: Verifies that `BuildPropertyAccessors` +returns `"get; set;"` (without a prefix) for a protected property whose get and set accessors are +both protected, confirming that redundant accessor prefixes are suppressed when they match the +property's declared accessibility. This scenario is tested by +`DotNetEmitter_BuildPropertyAccessors_ProtectedProperty_DoesNotPrefixAccessors`. diff --git a/docs/verification/api-mark-dot-net/dot-net-generator.md b/docs/verification/api-mark-dot-net/dot-net-generator.md index 09b327e..b30ce42 100644 --- a/docs/verification/api-mark-dot-net/dot-net-generator.md +++ b/docs/verification/api-mark-dot-net/dot-net-generator.md @@ -41,6 +41,9 @@ network dependency, or privileged configuration is needed. and parameter descriptions on their generated member detail pages; the full pipeline from Mono.Cecil inheritance mapping through XmlDocReader resolution to emitted Markdown is exercised end-to-end. +- `Parse` throws `FileNotFoundException` when the assembly path does not exist on disk, + and this check occurs before the XML documentation path is verified. +- The `DotNetGenerator` constructor throws `ArgumentNullException` when `options` is null. ### Test Scenarios @@ -161,6 +164,17 @@ tested by `DotNetGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCase page includes documentation for both case-colliding members. This scenario is tested by `DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers`. +**Missing assembly path throws FileNotFoundException**: Verifies that calling `Parse` when the +configured assembly path does not exist throws `FileNotFoundException` with the missing path, +giving callers a precise fail-fast error before any XML documentation path check or Mono.Cecil +I/O is attempted. AssemblyPath is checked before XmlDocPath. This scenario is tested by +`DotNetGenerator_Parse_MissingAssemblyPath_ThrowsFileNotFoundException`. + +**Null options throws ArgumentNullException**: Verifies that passing `null` to the +`DotNetGenerator` constructor throws `ArgumentNullException`, preventing a confusing +`NullReferenceException` at an unpredictable point during generation. This scenario is tested +by `DotNetGenerator_Constructor_NullOptions_ThrowsArgumentNullException`. + **Single-file output writes a complete api.md tree**: Verifies that when `OutputFormat.SingleFile` is configured, the generator produces exactly one writer keyed `api`, containing an H1 assembly title, H2 namespace heading, H3 type heading (e.g., `SampleClass`), H4 member headings with @@ -169,171 +183,26 @@ compact bullet-list paragraph (`- **MemberName**: description`) summarizing a ty This scenario is tested by `DotNetGenerator_Generate_SingleFileOutput_WritesSingleApiMarkdown`. -### Unit Test Scenarios - -**DotNetAstModel namespaces are sorted alphabetically**: Verifies that `DotNetAstModel.AllNamespaces` -returns all parsed namespaces in ordinal alphabetical order so that downstream pages list namespaces -in a stable, deterministic sequence. This scenario is tested by -`DotNetAstModel_AllNamespaces_ReturnsAlphabeticallySorted`. - -**DotNetAstModel namespace dictionary contains the fixture namespace**: Verifies that -`DotNetAstModel.ByNamespace` contains an entry for the fixture namespace after parsing, confirming -that type-lookup by exact namespace name is reliable. This scenario is tested by -`DotNetAstModel_ByNamespace_ContainsFixtureNamespace`. - -**DotNetAstModel root-namespace list is non-empty**: Verifies that `DotNetAstModel.RootNamespaces` -is populated after parsing the fixture assembly, confirming that the root-namespace index used when -building namespace folder paths is correctly initialized. This scenario is tested by -`DotNetAstModel_RootNamespaces_ContainsFixtureNamespace`. - -**DotNetAstModel options property returns construction-time options**: Verifies that -`DotNetAstModel.Options` returns the same `DotNetGeneratorOptions` instance that was passed at -construction, confirming that configuration is preserved through the parse step for use during -emission. This scenario is tested by `DotNetAstModel_Options_ReturnsOptionsPassedAtConstruction`. - -**DotNetAstModel assembly property returns the loaded assembly**: Verifies that -`DotNetAstModel.Assembly` is non-null and has the expected name after parsing, confirming that the -Mono.Cecil `AssemblyDefinition` is retained in the model for member iteration during emission. This -scenario is tested by `DotNetAstModel_Assembly_ReturnsLoadedAssembly`. - -**DotNetAstModel resolver property is non-null**: Verifies that `DotNetAstModel.Resolver` is -non-null after parsing, confirming that the type-reference resolver used during link generation is -initialized as part of the model. This scenario is tested by `DotNetAstModel_Resolver_IsNotNull`. - -**DotNetEmitter rejects a null factory with an ArgumentNullException**: Verifies that calling -`DotNetEmitter.Emit` with a null factory throws `ArgumentNullException` before any I/O is attempted, -providing a clear failure rather than a misleading null-reference error from within a file-write -operation. This scenario is tested by `DotNetEmitter_Emit_NullFactory_ThrowsArgumentNullException`. - -**DotNetEmitter dispatches GradualDisclosure format to produce multiple files**: Verifies that when -`OutputFormat.GradualDisclosure` is configured the emitter produces more than one Markdown writer, -confirming that the dispatch path routes to the gradual-disclosure emitter and not the single-file -emitter. This scenario is tested by -`DotNetEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles`. - -**DotNetEmitter dispatches SingleFile format to produce exactly one api file**: Verifies that when -`OutputFormat.SingleFile` is configured the emitter produces exactly one writer keyed `api`, -confirming that the dispatch path routes to the single-file emitter. This scenario is tested by -`DotNetEmitter_Emit_SingleFileFormat_ProducesSingleApiFile`. - -**GetNamespaceFolderPath returns the dotted name for a root namespace**: Verifies that a namespace -that is itself a configured root namespace returns its full dotted name as the folder path, so -namespace pages land at the expected location in the output tree. This scenario is tested by -`DotNetEmitter_GetNamespaceFolderPath_RootNamespace_ReturnsDottedName`. - -**GetNamespaceFolderPath returns a slash-separated path for a child namespace**: Verifies that a -namespace that is a child of a configured root returns a slash-separated path (root dotted name, -then `/`, then the child suffix), so child namespace pages land under the correct parent folder. -This scenario is tested by -`DotNetEmitter_GetNamespaceFolderPath_ChildNamespace_ReturnsSlashSeparated`. - -**DotNetEmitterGradualDisclosure creates the api index page**: Verifies that the -gradual-disclosure emitter creates the `api` writer key, confirming that the top-level assembly -entrypoint is emitted as the first page in the output tree. This scenario is tested by -`DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesApiIndexPage`. - -**DotNetEmitterGradualDisclosure creates a namespace page for the fixture namespace**: Verifies -that the gradual-disclosure emitter creates a writer whose key contains the fixture namespace name, -confirming that namespace summary pages are emitted for all discovered namespaces. This scenario is -tested by `DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesNamespacePage`. - -**DotNetEmitterGradualDisclosure creates a type page for SampleClass**: Verifies that the -gradual-disclosure emitter creates a writer whose key contains `SampleClass`, confirming that -per-type pages are emitted for all visible types in each namespace. This scenario is tested by -`DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesTypePage`. - -**DotNetEmitterGradualDisclosure api index page heading contains the assembly name**: Verifies that -the api index page includes a heading containing the fixture assembly name, confirming that the -top-level heading identifies the documented assembly so AI readers know its scope immediately. This -scenario is tested by -`DotNetEmitterGradualDisclosure_Emit_ValidModel_ApiIndexContainsAssemblyNameHeading`. - -**DotNetEmitterSingleFile creates exactly one writer**: Verifies that the single-file emitter -produces exactly one Markdown writer, confirming that all documentation is consolidated into a -single file and no additional writers are created. This scenario is tested by -`DotNetEmitterSingleFile_Emit_ValidModel_CreatesExactlyOneWriter`. - -**DotNetEmitterSingleFile creates only the api writer**: Verifies that the single writer produced -by the single-file emitter is keyed as `api`, confirming that the output file name follows the -established convention. This scenario is tested by -`DotNetEmitterSingleFile_Emit_ValidModel_CreatesApiFileOnly`. - -**DotNetEmitterSingleFile api file contains an assembly-level heading**: Verifies that the single -output file includes a heading containing the fixture assembly name, confirming that the top-level -section identifies the documented assembly. This scenario is tested by -`DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsAssemblyHeading`. - -**DotNetEmitterSingleFile api file contains a namespace-level heading**: Verifies that the single -output file includes a heading containing the fixture namespace name, confirming that namespaces are -represented as sections in the consolidated document. This scenario is tested by -`DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsNamespaceHeading`. - -**DotNetEmitterSingleFile api file contains a type-level heading for SampleClass**: Verifies that -the single output file includes a heading for `SampleClass`, confirming that all visible types -receive a dedicated section in the consolidated document. This scenario is tested by -`DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsTypeHeading`. - -**TypeLinkResolver returns an empty string for a null type reference**: Verifies that -`TypeLinkResolver.Linkify` returns an empty string rather than throwing when passed a null type -reference, providing a safe no-op for callers that may encounter unresolvable references during -table cell generation. This scenario is tested by -`TypeLinkResolver_Linkify_NullTypeRef_ReturnsEmptyString`. - -**TypeLinkResolver resolves System.Int32 to the C# alias "int"**: Verifies that -`TypeLinkResolver.Linkify` returns the C# primitive alias `int` for `System.Int32`, confirming -that the alias table is applied to well-known system types so table cells contain readable C# names. -This scenario is tested by `TypeLinkResolver_Linkify_Int32_ReturnsCSharpAlias`. - -**TypeLinkResolver resolves System.String to the C# alias "string"**: Verifies that -`TypeLinkResolver.Linkify` returns the C# primitive alias `string` for `System.String`, confirming -that string references in table cells are shown using the idiomatic C# keyword. This scenario is -tested by `TypeLinkResolver_Linkify_StringType_ReturnsCSharpAlias`. - -**TypeLinkResolver returns a Markdown link for an intra-assembly type when generateLinks is true**: -Verifies that an intra-assembly type resolves to a Markdown link when `generateLinks` is true, -confirming that the link-generation mode produces clickable cross-references in table cells. This -scenario is tested by -`TypeLinkResolver_Linkify_GenerateLinksTrue_IntraAssemblyType_ReturnsMarkdownLink`. - -**TypeLinkResolver returns plain text for an intra-assembly type when generateLinks is false**: -Verifies that an intra-assembly type resolves to plain text when `generateLinks` is false, -confirming that the no-link mode suppresses markup for contexts where links would not render. This -scenario is tested by -`TypeLinkResolver_Linkify_GenerateLinksFalse_IntraAssemblyType_ReturnsPlainText`. - -**Enum type signature omits System.Enum base class**: Verifies that the type signature for an -enum does not include `System.Enum` or the `:` inheritance separator, confirming that -well-known implicit base types are suppressed and the signature remains clean and readable. This -scenario is tested by `DotNetGenerator_Generate_EnumTypeSignature_HasNoBaseClass`. - -**SampleImplementation type signature shows its implemented interface**: Verifies that the type -page for `SampleImplementation` includes `: ISampleInterface` in its signature block, -confirming that explicitly declared interface implementations appear in the generated type -signature. This scenario is tested by +**Namespace description from NamespaceDoc appears on namespace page**: Verifies that when a +namespace carries a NamespaceDoc carrier class, its XML summary is emitted as a paragraph on +the namespace page, confirming that developer-authored namespace descriptions appear in generated +output. This scenario is tested by +`DotNetGenerator_NamespacePage_NamespaceDocClass_ExcludedFromTypeListing`. + +**Type signature includes direct base class or interface**: Verifies that the type signature +code block for a class that implements an interface includes the interface name in the +`:` clause, confirming that direct inheritance is visible at a glance without opening the source +file. This scenario is tested by `DotNetGenerator_Generate_SampleImplementation_TypeSignatureShowsInterface`. -**Method with example emits a code block on its member detail page**: Verifies that a method -whose XML documentation contains an `` element causes a `csharp` code block to -appear on the member's gradual-disclosure detail page, confirming that example content is -rendered for AI and developer consumers. This scenario is tested by -`DotNetGenerator_Generate_MethodWithExample_EmitsCodeBlockOnMemberPage`. - -**Method with example emits a code block in single-file output**: Verifies that the same -`` block renders a `csharp` code block in the consolidated `api.md` produced -by the single-file emitter, confirming that example sections are not silently dropped in -single-file mode. This scenario is tested by -`DotNetGenerator_SingleFile_MethodWithExample_EmitsCodeBlock`. - -**Intra-assembly return type emits a Markdown link in the Returns table cell**: Verifies that -when a method returns a type defined in the same assembly, the Returns column of the Methods -table on the type page contains a Markdown link (e.g., `[SampleClass](SampleClass.md)`) rather -than plain text, confirming that the TypeLinkResolver emits intra-document navigation links in -gradual-disclosure mode. This scenario is tested by +**Table cells include Markdown links for intra-assembly types**: Verifies that the Returns +column in the type page Methods table contains a Markdown link for a method that returns an +intra-assembly type, confirming that readers can navigate directly to the referenced type page. +This scenario is tested by `DotNetGenerator_Generate_IntraAssemblyReturnType_EmitsMarkdownLinkInReturnsCell`. -**Method with an external non-System parameter type emits an External Types section**: Verifies -that when a method accepts a parameter whose type comes from an external non-System assembly, -the member detail page includes an `External Types` H2 section with a two-column table listing -the type name and its namespace, confirming that external type references are surfaced for -readers. This scenario is tested by +**External Types section appears when non-System types are referenced**: Verifies that a +member detail page contains an External Types section listing any non-System external types +referenced in its parameters or return type, confirming that readers have the context needed +to identify external dependencies without opening source code. This scenario is tested by `DotNetGenerator_Generate_ExternalNonSystemParameterType_EmitsExternalTypesSection`. diff --git a/docs/verification/api-mark-dot-net/type-link-resolver.md b/docs/verification/api-mark-dot-net/type-link-resolver.md index 3330311..fa53fec 100644 --- a/docs/verification/api-mark-dot-net/type-link-resolver.md +++ b/docs/verification/api-mark-dot-net/type-link-resolver.md @@ -22,9 +22,13 @@ output location is needed. - A null type reference returns an empty string. - `System.Int32` resolves to the C# alias `int`. - `System.String` resolves to the C# alias `string`. +- A System-namespace external type (e.g. `System.IO.Stream`) is returned as plain text and is not tracked as an external dependency. +- A non-System external type (e.g. `Acme.Widgets.Widget`) is returned as plain text and is added to the external types accumulator. - An intra-assembly type produces a Markdown link when `generateLinks` is `true`. - An intra-assembly type produces plain text when `generateLinks` is `false`. - A nullable generic parameter appends `?` when `isNullableAnnotated` is `true`. +- An array type appends the array rank suffix (e.g., `[]`) to the element type name. +- A generic container type appends angle-bracket notation listing the resolved type arguments. ### Test Scenarios @@ -54,3 +58,27 @@ scenario is tested by parameter linkified with `isNullableAnnotated: true` produces a `?` suffix. This scenario is tested by `TypeLinkResolver_Linkify_NullableGenericParameter_AppendsQuestionMark`. + +**Array type appends [] suffix**: Verifies that an array type reference produces +a result ending with `[]`, confirming that the array rank suffix is appended to the +element type name. This scenario is tested by +`TypeLinkResolver_Linkify_ArrayType_AppendsArraySuffix`. + +**Generic type renders type arguments in angle-bracket notation**: Verifies that a +generic instance type reference (e.g., `List`) produces a result containing +escaped angle brackets, confirming that the resolved type argument list is appended. +This scenario is tested by +`TypeLinkResolver_Linkify_GenericType_RendersTypeArguments`. + +**System-namespace external type returns plain text and is not tracked**: Verifies +that a type reference from a `System.*` namespace (e.g. `System.IO.Stream`) is +returned as plain text and is not added to the external types accumulator, because +System types are universally known and should not appear in the External Types +section. This scenario is tested by +`TypeLinkResolver_Linkify_SystemNamespaceExternalType_ReturnsPlainTextAndDoesNotTrack`. + +**Non-System external type returns plain text and is tracked**: Verifies that a +type reference from a non-System namespace (e.g. `Acme.Widgets.Widget`) is returned +as plain text and is added to the external types accumulator so that the consuming +emitter can emit an External Types section. This scenario is tested by +`TypeLinkResolver_Linkify_ExternalNonSystemType_ReturnsPlainNameAndTracksExternalType`. diff --git a/docs/verification/api-mark-dot-net/type-name-simplifier.md b/docs/verification/api-mark-dot-net/type-name-simplifier.md index e1193c1..d54dbe0 100644 --- a/docs/verification/api-mark-dot-net/type-name-simplifier.md +++ b/docs/verification/api-mark-dot-net/type-name-simplifier.md @@ -10,50 +10,62 @@ rules do not produce unexpected output. No dependencies are injected; no mocking ### Test Environment -The compiled fixture assembly (`ApiMark.DotNet.Fixtures.dll`) must be present at -test execution time. Tests are invoked via `dotnet test` in the standard .NET -test environment. No network or process dependencies are required beyond file -access to the fixture DLL. +TypeNameSimplifier tests apply pure string-transformation rules and have no dependency +on the compiled fixture assembly. The fixture assembly requirement is a shared test +project dependency that does not affect TypeNameSimplifier unit tests specifically. +Tests are invoked via `dotnet test` in the standard .NET test environment. No network +or process dependencies are required. ### Acceptance Criteria - All `TypeNameSimplifier` unit tests pass with zero failures. - Each of the seven simplification rules produces the expected simplified name when exercised individually. +- All 16 C# primitive aliases (`bool`, `byte`, `sbyte`, `short`, `ushort`, `int`, `uint`, + `long`, `ulong`, `float`, `double`, `decimal`, `char`, `string`, `object`, `void`) are + verified by dedicated `[InlineData]` cases in the primitive alias theory test. - Combined rule application on a type name that triggers multiple rules produces the expected final form. - Edge-case inputs — null context namespace, type with no applicable rules, deeply nested generic - arguments — do not cause exceptions and return a safe fallback. + arguments — do not cause exceptions and return a safe fallback. The null-context-namespace edge + case is verified by `TypeNameSimplifier_Simplify_NullContextNamespace_DoesNotThrow`. Nested- + namespace prefix stripping is verified by + `TypeNameSimplifier_Simplify_ContextNamespaceTypes_NestedNamespace_StripsSharedPrefix`. ### Test Scenarios -**Primitive type aliases are simplified to C# keywords**: Verifies that CLR primitive types such as -`System.Int32` and `System.Boolean` are simplified to their C# keyword equivalents `int` and `bool` -so generated documentation uses familiar language syntax. This scenario is tested by -`TypeNameSimplifier_Primitives_RenderLanguageAliases`. +**Primitive type aliases are simplified to C# keywords**: Verifies that all 16 CLR primitive +types (`System.Boolean`, `System.Byte`, `System.SByte`, `System.Int16`, `System.UInt16`, +`System.Int32`, `System.UInt32`, `System.Int64`, `System.UInt64`, `System.Single`, +`System.Double`, `System.Decimal`, `System.Char`, `System.String`, `System.Object`, and +`System.Void`) are simplified to their respective C# keyword equivalents (`bool`, `byte`, +`sbyte`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`, `float`, `double`, `decimal`, +`char`, `string`, `object`, and `void`) so generated documentation uses familiar language +syntax. This scenario is tested by +`TypeNameSimplifier_Simplify_Primitives_RenderLanguageAliases`. **Nullable value types are rendered with the ? suffix**: Verifies that `System.Nullable{T}` is simplified to `T?` so optional value types appear in the idiomatic C# nullable form. This scenario -is tested by `TypeNameSimplifier_NullableValueTypes_UseQuestionMarkForm`. +is tested by `TypeNameSimplifier_Simplify_NullableValueTypes_UseQuestionMarkForm`. **Generic type arguments are recursively simplified**: Verifies that generic type names such as `System.Collections.Generic.List{System.Int32}` are simplified to `List` including recursive simplification of each type argument. This scenario is tested by -`TypeNameSimplifier_GenericArguments_AreSimplifiedRecursively`. +`TypeNameSimplifier_Simplify_GenericArguments_AreSimplifiedRecursively`. **Array types use the C# bracket notation**: Verifies that CLR array types are simplified to the `T[]` form rather than the verbose CLR array representation, with the element type itself -simplified. This scenario is tested by `TypeNameSimplifier_ArrayType_ReturnsBracketNotation`. +simplified. This scenario is tested by `TypeNameSimplifier_Simplify_ArrayType_ReturnsBracketNotation`. **Common well-known namespace types use their short names**: Verifies that types from `System.Collections.Generic` (such as `List`, `Dictionary`, `IEnumerable`) and `System.Threading.Tasks` (such as `Task`, `Task`) are simplified to their short names without the namespace prefix so generated signatures remain compact. This scenario is tested by -`TypeNameSimplifier_WellKnownNamespaceTypes_RenderWithoutNamespace`. +`TypeNameSimplifier_Simplify_WellKnownNamespaceTypes_RenderWithoutNamespace`. **Context namespace types drop the shared prefix**: Verifies that a type in the same namespace as the context drops its namespace prefix entirely, and a type in a nested namespace drops only the -shared prefix. This scenario is tested by `TypeNameSimplifier_ContextNamespaceTypes_RenderWithoutSharedPrefix`. +shared prefix. This scenario is tested by `TypeNameSimplifier_Simplify_ContextNamespaceTypes_RenderWithoutSharedPrefix`. **Nullable reference annotation appends ? combined with primitive alias**: Verifies that when a reference type carries a nullable annotation and `isNullableAnnotated` is true, the simplified name diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index 0992780..b52cb9d 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -89,22 +89,15 @@ and the internal implementation of OTS items are also excluded. ```text test/ -├── ApiMark.Core.TestHelpers/ -│ ├── InMemoryMarkdownWriterFactory.cs - in-memory IMarkdownWriterFactory test double -│ ├── InMemoryMarkdownWriter.cs - in-memory IMarkdownWriter test double -│ └── InMemoryContext.cs - in-memory IContext test double -│ -│ (Test Runners) -├── ApiMark.Core.Tests/ - unit tests for Core contracts -├── ApiMark.DotNet.Tests/ - unit tests for DotNetGenerator and TypeNameSimplifier -├── ApiMark.Cpp.Tests/ - unit tests for CppGenerator -├── ApiMark.Vhdl.Tests/ - unit tests for VhdlGenerator -├── ApiMark.MSBuild.Tests/ - unit tests for ApiMarkTask -├── ApiMark.Tool.Tests/ - integration tests for the CLI tool -│ -│ (Fixtures — provide test inputs only; not test runners) -├── ApiMark.DotNet.Fixtures/ - multi-target fixture assembly for DotNet integration tests -└── ApiMark.Cpp.Fixtures/ - C++ fixture headers for CppGenerator integration tests +├── ApiMark.Core.TestHelpers/ - in-memory test doubles for Core contracts +├── ApiMark.Core.Tests/ - unit tests for Core contracts +├── ApiMark.DotNet.Tests/ - unit tests for DotNetGenerator and TypeNameSimplifier +├── ApiMark.Cpp.Tests/ - unit tests for CppGenerator +├── ApiMark.Vhdl.Tests/ - unit tests for VhdlGenerator +├── ApiMark.MSBuild.Tests/ - unit tests for ApiMarkTask +├── ApiMark.Tool.Tests/ - integration tests for the CLI tool +├── ApiMark.DotNet.Fixtures/ - multi-target fixture assembly for DotNet integration tests +└── ApiMark.Cpp.Fixtures/ - C++ fixture headers for CppGenerator integration tests ``` OTS items: @@ -245,7 +238,7 @@ Local items have parallel artifacts in: `docs/verification/api-mark-tool/self-test/validation.md` - Source: `src/ApiMark.Core/`, `src/ApiMark.Cpp/`, `src/ApiMark.DotNet/`, `src/ApiMark.Vhdl/`, `src/ApiMark.MSBuild/`, `src/ApiMark.Tool/` -- Tests: `test/ApiMark.Core.Tests/`, `test/ApiMark.Cpp.Tests/`, +- Tests: `test/ApiMark.Core.TestHelpers/`, `test/ApiMark.Core.Tests/`, `test/ApiMark.Cpp.Tests/`, `test/ApiMark.DotNet.Tests/`, `test/ApiMark.Vhdl.Tests/`, `test/ApiMark.MSBuild.Tests/`, `test/ApiMark.Tool.Tests/` - Fixtures: `test/ApiMark.DotNet.Fixtures/`, `test/ApiMark.Cpp.Fixtures/` diff --git a/docs/verification/ots.md b/docs/verification/ots.md index a0db806..7d4c229 100644 --- a/docs/verification/ots.md +++ b/docs/verification/ots.md @@ -27,6 +27,30 @@ verification scenarios against the same fixture assemblies used for baseline qua change in discovered members, rendered signatures, or generated file layout is treated as a regression candidate and must be reviewed before the upgrade is accepted. +## Mono.Cecil + +Mono.Cecil is used by `ApiMark.DotNet` as an OTS component for .NET assembly reflection. +It is not modified; ApiMark reads assembly metadata, type and member definitions, generic +parameters, nullable annotations, and custom attributes through Mono.Cecil's public API +without altering the component in any way. + +**Verification approach**: ApiMark exercises the subset of Mono.Cecil's API surface that it +depends on through its existing integration tests. `DotNetGenerator` tests open the fixture +assembly via `AssemblyDefinition.ReadAssembly`, enumerate types and members, inspect +accessibility modifiers and custom attributes, and feed the resulting metadata into Markdown +generation. These tests collectively verify that Mono.Cecil correctly discovers namespace and +type structure, member signatures, generic parameters, nullable annotations, obsolete markers, +and inheritance chains for the pattern of assemblies ApiMark is expected to document. + +**Qualification evidence**: The Mono.Cecil package version is pinned in the project's NuGet +references. ApiMark uses only the public Mono.Cecil API. All `ApiMark.DotNet` tests must pass +with each version of Mono.Cecil to confirm continued compatibility. + +**Regression criteria**: All `ApiMark.DotNet` tests pass with each Mono.Cecil version update. +Any change in discovered members, rendered signatures, or generated file layout relative to +the fixture assembly baseline is treated as a regression candidate and must be reviewed before +the upgrade is accepted. + ## clang ApiMark verifies the clang integration by testing the exact externally supplied behavior that diff --git a/review-template.md b/review-template.md new file mode 100644 index 0000000..744a522 --- /dev/null +++ b/review-template.md @@ -0,0 +1,266 @@ +# _[Review Title]_ + +## 1. Introduction + +### 1.1 Purpose + +This document records the formal review of a set of project files. + +### 1.2 Scope + +_[Describe the scope of this review, including which project, release, or change is being reviewed.]_ + +### 1.3 Outcomes + +Each check must be recorded with one of the following outcomes: + +| Outcome | Meaning | +| :------ | :------ | +| Pass | The check was performed and the criterion is satisfied | +| Fail | The check was performed and the criterion is not satisfied | +| N/A | The check does not apply; justification is required | + +### 1.4 Review Details + +| Field | Value | +| :---- | :---- | +| Project | _[Project name]_ | +| Review ID | _[Review identifier]_ | +| Review Title | _[Review title]_ | +| Fingerprint | _[Fingerprint of the complete file set]_ | +| Review Date | _[YYYY-MM-DD]_ | + +### 1.5 Reviewers + +| Name | Role | Organization | Signature | Date | +| :--- | :--- | :----------- | :-------- | :--- | +| _[Reviewer name]_ | _[Role]_ | _[Organization]_ | _[Signature]_ | _[YYYY-MM-DD]_ | +| _[Reviewer name]_ | _[Role]_ | _[Organization]_ | _[Signature]_ | _[YYYY-MM-DD]_ | + +### 1.6 Files Under Review + +| File | +| :--- | +| _[filename]_ | + +--- + +## 2. Review Checklist + +### 2.1 Requirements Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no requirements files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| REQ-01 | All requirements have a unique identifier | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-02 | All requirements are unambiguous (only one valid interpretation) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-03 | All requirements are testable (compliance can be demonstrated by a test) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-04 | All requirements are consistent (no requirement contradicts another) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-05 | All requirements are complete (no TBDs, undefined terms, or missing information) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-06 | All requirements are verifiable (can be objectively confirmed as met or not met) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-07 | No compound requirements are present (each requirement expresses a single testable criterion) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-08 | No requirements are missing (all expected behaviors and constraints are specified) | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.2 Design Documentation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no design documentation files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| DES-01 | Design documentation clearly describes the purpose of the component or feature | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DES-02 | Design documentation covers the necessary implementation details | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DES-03 | Design documentation describes how the code is interfaced (APIs, inputs, outputs) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DES-04 | Design documentation describes the expected normal operation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DES-05 | Design documentation describes the expected error handling | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.3 Technical Documentation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no general technical documentation files (e.g., user guides, +API references, README files, release notes). If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| DOC-01 | Documentation is free of technical inaccuracies | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DOC-02 | Documentation is consistent with the current implementation and requirements | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DOC-03 | All referenced external documents and dependencies are correctly identified | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DOC-04 | Documentation is free of spelling and grammar errors | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.4 Code Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| CODE-01 | Code conforms to the project coding standards and style guide | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-02 | No obvious resource leaks are present (file handles, connections, memory) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-03 | No hardcoded values are present that should be configurable | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-04 | Each unit or function has a single, well-defined responsibility | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-05 | Code is written at the appropriate level of abstraction | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-06 | Code has an appropriate amount of extensibility for its context | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.5 Logic Error Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| LOGIC-01 | Code does only what is intended (no unintended side effects or behaviors) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| LOGIC-02 | All significant inputs and boundary conditions are handled correctly | Pass / Fail / N/A | _Required if Fail or N/A_ | +| LOGIC-03 | Concurrency and threading concerns are identified and addressed | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.6 Error Handling & Logging Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| ERR-01 | Error handling follows the approach described in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| ERR-02 | The logging volume and level of detail are appropriate | Pass / Fail / N/A | _Required if Fail or N/A_ | +| ERR-03 | Error messages are user-friendly and actionable | Pass / Fail / N/A | _Required if Fail or N/A_ | +| ERR-04 | Error messages and log entries do not leak sensitive data | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.7 Usability / Accessibility Checks + +**Applicable:** Yes / No + +_Skip this section if usability and accessibility are not relevant to the files under review. +If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| USE-01 | The feature or API is easy to use correctly | Pass / Fail / N/A | _Required if Fail or N/A_ | +| USE-02 | All public APIs are well documented | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.8 Test Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no test code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| TEST-01 | Tests cover expected (happy-path) behavior | Pass / Fail / N/A | _Required if Fail or N/A_ | +| TEST-02 | Tests cover error conditions and boundary cases | Pass / Fail / N/A | _Required if Fail or N/A_ | +| TEST-03 | Tests are independent and repeatable (no shared mutable state, no ordering dependency) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| TEST-04 | Test names clearly describe the behavior being verified | Pass / Fail / N/A | _Required if Fail or N/A_ | +| TEST-05 | New test cases are added for new functionality or defect fixes | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.9 Security Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files or if security concerns are not +relevant to the files under review. If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| SEC-01 | No obvious security vulnerabilities are present (e.g., injection flaws, hardcoded credentials) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| SEC-02 | Authentication and authorization are handled correctly (see design documentation) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| SEC-03 | Sensitive data is stored and transmitted securely | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.10 Code Readability Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| READ-01 | Code is easy to understand | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-02 | Methods and functions are small enough to be easily understood | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-03 | Symbols (variables, functions, classes) are well named | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-04 | Code is located in the correct place in the codebase | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-05 | Flow of control can be easily followed | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-06 | Data flow is understandable | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-07 | Comments are provided where the code is non-obvious | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-08 | No debug artifacts or commented-out code have been left in the codebase | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.11 Requirements vs Documentation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no requirements files or no general technical documentation +files. If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| REQDOC-01 | All reviewed requirements are addressed in the general technical documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQDOC-02 | No reviewed requirement is contradicted by the general technical documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.12 Requirements vs Implementation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no requirements files or no source code +files. If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| REQIMP-01 | All requirements under review are addressed by the implementation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQIMP-02 | No requirement is contradicted by the implementation | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.13 Requirements vs Testing Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no requirements files or no test code files. +If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| REQTEST-01 | Every requirement under review is covered by at least one test | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQTEST-02 | Tests verify the behavior described in each requirement | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.14 Code vs Design Documentation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files or no design documentation +files. If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| CODEDOC-01 | The code correctly implements the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODEDOC-02 | All public APIs and interfaces are documented in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODEDOC-03 | Non-obvious algorithms and significant design decisions are explained in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODEDOC-04 | No important code details are missing from the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | + +--- + +## 3. Conclusion + +### 3.1 Summary of Findings + +_List any checks recorded as Fail, and any observations that do not constitute a failure +but should be noted for the project record:_ + +| # | Check | Finding | +| :-- | :---- | :------ | +| _[check ID]_ | _[check description]_ | _[describe the finding]_ | + +### 3.2 Overall Outcome + +**Overall Outcome:** Pass / Fail + +_State the basis for the overall outcome, including any conditions or follow-up actions +required before the review can be considered approved:_ diff --git a/src/ApiMark.DotNet/ApiVisibility.cs b/src/ApiMark.DotNet/ApiVisibility.cs deleted file mode 100644 index 44dde0e..0000000 --- a/src/ApiMark.DotNet/ApiVisibility.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ApiMark.DotNet; - -/// Specifies which members are included in the generated API documentation. -public enum ApiVisibility -{ - /// Include only public members. - Public, - - /// Include public and protected members. - PublicAndProtected, - - /// Include all members regardless of access modifier. - All, -} diff --git a/src/ApiMark.DotNet/DotNetAstModel.cs b/src/ApiMark.DotNet/DotNetAstModel.cs index b36cc18..62135e2 100644 --- a/src/ApiMark.DotNet/DotNetAstModel.cs +++ b/src/ApiMark.DotNet/DotNetAstModel.cs @@ -1,3 +1,6 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + using ApiMark.Core; using Mono.Cecil; @@ -50,9 +53,9 @@ internal sealed record MethodDocContext( /// Documentation index for namespace-level lookups. /// Type link resolver for namespace page table cells. internal sealed record NamespaceDocContext( - List AllNamespaces, - Dictionary> ByNamespace, - List RootNamespaces, + IReadOnlyList AllNamespaces, + IReadOnlyDictionary> ByNamespace, + IReadOnlyList RootNamespaces, IReadOnlyDictionary NamespaceDescriptions, XmlDocReader XmlDocs, TypeLinkResolver Resolver); @@ -80,9 +83,9 @@ internal sealed class DotNetAstModel internal DotNetAstModel( AssemblyDefinition assembly, XmlDocReader xmlDocs, - List allNamespaces, - Dictionary> byNamespace, - List rootNamespaces, + IReadOnlyList allNamespaces, + IReadOnlyDictionary> byNamespace, + IReadOnlyList rootNamespaces, IReadOnlyDictionary namespaceDescriptions, TypeLinkResolver resolver, DotNetGeneratorOptions options) @@ -104,13 +107,13 @@ internal DotNetAstModel( internal XmlDocReader XmlDocs { get; } /// Gets all namespace names present in the assembly, ordered alphabetically. - internal List AllNamespaces { get; } + internal IReadOnlyList AllNamespaces { get; } /// Gets the visible types grouped by their namespace name. - internal Dictionary> ByNamespace { get; } + internal IReadOnlyDictionary> ByNamespace { get; } /// Gets the root namespaces identified in the assembly. - internal List RootNamespaces { get; } + internal IReadOnlyList RootNamespaces { get; } /// Gets the optional namespace descriptions sourced from NamespaceDoc carriers. internal IReadOnlyDictionary NamespaceDescriptions { get; } diff --git a/src/ApiMark.DotNet/DotNetEmitter.cs b/src/ApiMark.DotNet/DotNetEmitter.cs index aa1cfe0..efd4e5c 100644 --- a/src/ApiMark.DotNet/DotNetEmitter.cs +++ b/src/ApiMark.DotNet/DotNetEmitter.cs @@ -42,6 +42,8 @@ internal DotNetEmitter(DotNetAstModel model) public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) { ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(context); // Dispose the assembly after emit regardless of success or failure using (Model.Assembly) @@ -101,7 +103,7 @@ internal static string GetNamespaceFolderPath(string namespaceName, IReadOnlyLis /// The parent namespace name. /// All namespace names to search. /// An enumerable of immediate child namespace names. - internal static IEnumerable GetImmediateChildNamespaces(string parent, List allNamespaces) + internal static IEnumerable GetImmediateChildNamespaces(string parent, IReadOnlyList allNamespaces) { var prefix = parent + "."; return allNamespaces.Where(n => @@ -575,6 +577,10 @@ internal static string BuildTypeSignature(TypeDefinition type, string contextNam { classModifier = "static "; } + else if (type.IsAbstract) + { + classModifier = "abstract "; + } else if (type.IsSealed) { classModifier = "sealed "; @@ -686,29 +692,49 @@ internal static string BuildPropertySignature(PropertyDefinition prop, string co return $"{accessibility} {typeName} {prop.Name} {{ {accessors} }}"; } - /// Builds the accessor portion of a property signature (e.g. get; internal set;). + /// Builds the accessor portion of a property signature (e.g. get; internal set; or get; init; for init-only setters). + /// + /// The property-level accessibility is determined by the more permissive of the get and set + /// accessors (or the single accessor when only one exists). An individual accessor is prefixed + /// with its own accessibility keyword only when it is strictly less permissive than the + /// property-level keyword (e.g., protected { get; private set; }). A property where + /// both accessors share the same accessibility as the property itself renders without per-accessor + /// prefixes (e.g., protected { get; set; }). + /// /// The property definition. /// The accessor string to embed in the property signature. internal static string BuildPropertyAccessors(PropertyDefinition prop) { var parts = new List(); + var propertyAccessibility = GetAccessibilityKeyword(prop.GetMethod ?? prop.SetMethod!); if (prop.GetMethod != null) { - // Only prefix the getter when it is less permissive than the property's declared access - var prefix = prop.GetMethod.IsPublic ? string.Empty : $"{GetAccessibilityKeyword(prop.GetMethod)} "; + var accessorKeyword = GetAccessibilityKeyword(prop.GetMethod); + var prefix = accessorKeyword != propertyAccessibility ? $"{accessorKeyword} " : string.Empty; parts.Add($"{prefix}get;"); } if (prop.SetMethod != null) { - var prefix = prop.SetMethod.IsPublic ? string.Empty : $"{GetAccessibilityKeyword(prop.SetMethod)} "; - parts.Add($"{prefix}set;"); + var accessorKeyword = GetAccessibilityKeyword(prop.SetMethod); + var prefix = accessorKeyword != propertyAccessibility ? $"{accessorKeyword} " : string.Empty; + var keyword = IsInitOnlySetter(prop.SetMethod) ? "init" : "set"; + parts.Add($"{prefix}{keyword};"); } return string.Join(" ", parts); } + /// Returns when the property setter is an init-only accessor (C# 9+). + /// The property set method to inspect. + /// when the setter carries a modreq(IsExternalInit) modifier. + private static bool IsInitOnlySetter(MethodDefinition setMethod) + { + return setMethod.ReturnType is RequiredModifierType modreq && + modreq.ModifierType.FullName == "System.Runtime.CompilerServices.IsExternalInit"; + } + /// Builds a human-readable C# field declaration signature. /// The field definition. /// Used to simplify the field type name. @@ -789,6 +815,12 @@ internal static string BuildEventSignature(EventDefinition evt, string contextNa internal static string GetAccessibilityKeyword(EventDefinition evt) => evt.AddMethod != null ? GetAccessibilityKeyword(evt.AddMethod) : "private"; + /// + /// Builds the full display name for a method overload, including the simplified parameter + /// type list in parentheses (e.g. Process(int, string)). + /// + /// The method definition to build a display name for. + /// A human-readable method name including parenthesized parameter types. internal static string BuildMethodDisplayName(MethodDefinition method) { var baseName = GetMethodGroupName(method); @@ -797,6 +829,13 @@ internal static string BuildMethodDisplayName(MethodDefinition method) return $"{baseName}({parameters})"; } + /// + /// Returns the sanitized file name (without extension) for a method's dedicated detail page. + /// Constructors use the declaring type's simple name; all other methods use the method name. + /// + /// The method whose file name to compute. + /// The type that declares the method; used to strip arity from constructor file names. + /// A filesystem-safe name string suitable for use as a Markdown file name. internal static string BuildMethodFileName(MethodDefinition method, TypeDefinition declaringType) { return method.Name == ConstructorMethodName @@ -804,12 +843,29 @@ internal static string BuildMethodFileName(MethodDefinition method, TypeDefiniti : method.Name; } + /// + /// Builds the group-level display name for a method, optionally appending an overload count + /// suffix when a type page's member table lists multiple overloads under one row. + /// + /// The representative method for the overload group. + /// + /// The total number of overloads in the group. When greater than one, the suffix + /// ({count} overloads) is appended to the base name. + /// + /// A human-readable group display name string. internal static string GetMethodGroupDisplayName(MethodDefinition method, int overloadCount) { var baseName = GetMethodGroupName(method); return overloadCount > 1 ? $"{baseName} ({overloadCount} overloads)" : baseName; } + /// + /// Returns the base display name for a method as it appears in documentation headings and + /// type page member rows: the declaring type's simple name for constructors, the C# operator + /// symbol for operator methods, and the raw method name for all other methods. + /// + /// The method definition to name. + /// A human-readable base name string (no parameter list). internal static string GetMethodGroupName(MethodDefinition method) { if (method.Name == ConstructorMethodName) diff --git a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs index 80d8d88..2a2d9fe 100644 --- a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs +++ b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs @@ -1,3 +1,6 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + using ApiMark.Core; using Mono.Cecil; using static ApiMark.DotNet.DotNetEmitter; @@ -35,7 +38,7 @@ internal DotNetEmitterGradualDisclosure(DotNetEmitter emitter, DotNetAstModel mo /// Dispatches to . /// Factory for creating per-file Markdown writers. /// Output configuration (unused for gradual-disclosure format). - /// Output channel for informational messages. + /// Output channel for informational messages (unused for gradual-disclosure format). internal void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) { EmitGradualDisclosure(factory); @@ -438,47 +441,25 @@ private static void WriteMemberPage( return; } - var signature = BuildMemberSignature(member, ctx.NamespaceName); - memberWriter.WriteSignature("csharp", signature); - - // Always emit a summary paragraph — use the placeholder when no doc is present - var summary = ctx.XmlDocs.GetSummary(memberId); - memberWriter.WriteParagraph(!string.IsNullOrEmpty(summary) ? summary : DotNetEmitter.NoDescriptionPlaceholder); - - var returns = ctx.XmlDocs.GetReturns(memberId); - if (!string.IsNullOrEmpty(returns)) - { - memberWriter.WriteParagraph($"**Returns:** {returns}"); - } - - // Emit exception table when documented exceptions exist - var exceptions = ctx.XmlDocs.GetExceptionDetails(memberId); - if (exceptions.Count > 0) - { - var exHeaders = new[] { "Exception", DotNetEmitter.DescriptionColumnHeader }; - var exRows = exceptions.Select(e => new[] { e.Type, e.Description ?? string.Empty }); - memberWriter.WriteTable(exHeaders, exRows); - } - - var remarks = ctx.XmlDocs.GetRemarks(memberId); - if (!string.IsNullOrEmpty(remarks)) - { - memberWriter.WriteParagraph(remarks); - } - - foreach (var (isCode, content) in ctx.XmlDocs.GetExampleParts(memberId)) - { - if (isCode) - { - memberWriter.WriteCodeBlock("csharp", content); - } - else - { - memberWriter.WriteParagraph(content); - } - } + WriteNonMethodMemberContent(memberWriter, member, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, memberCurrentFolder, new SortedSet())); } + /// + /// Writes a single consolidated Markdown page for a pure method overload group — all methods + /// sharing the same base name and therefore the same sanitized file name. + /// + /// + /// Each overload is distinguished by an H2 heading showing its full parameter signature via + /// . External type references are accumulated + /// across all overloads and emitted as a shared External Types section at the bottom of the page. + /// + /// Factory for creating the output Markdown writer. + /// Full namespace name of the declaring type; used to simplify type names. + /// File-system folder path for the namespace. + /// The type definition that declares the overload group. + /// Ordered list of overload methods (at least one element). + /// Documentation index for per-overload member-ID lookups. + /// Type link resolver for table cell link generation. private static void WriteMethodOverloadPage( IMarkdownWriterFactory factory, string namespaceName, diff --git a/src/ApiMark.DotNet/DotNetEmitterSingleFile.cs b/src/ApiMark.DotNet/DotNetEmitterSingleFile.cs index d84ea8e..a3f4d71 100644 --- a/src/ApiMark.DotNet/DotNetEmitterSingleFile.cs +++ b/src/ApiMark.DotNet/DotNetEmitterSingleFile.cs @@ -77,6 +77,10 @@ private void EmitSingleFile(IMarkdownWriterFactory factory, EmitConfig config) // because all content is inline and same-name anchors can collide across types var noLinkResolver = new TypeLinkResolver(_model.RootNamespaces, generateLinks: false); + // Allocate a single throw-away accumulator for all Linkify calls — generateLinks is false so + // the set is never populated, and no External Types section is emitted in single-file output + var sharedExternalTypes = new SortedSet(); + foreach (var namespaceName in _model.AllNamespaces) { writer.WriteHeading(depth + 1, namespaceName); @@ -97,7 +101,7 @@ private void EmitSingleFile(IMarkdownWriterFactory factory, EmitConfig config) foreach (var type in nsTypes) { - WriteSingleFileTypeSections(writer, depth, namespaceName, namespaceFolderPath, type, noLinkResolver); + WriteSingleFileTypeSections(writer, depth, namespaceName, namespaceFolderPath, type, noLinkResolver, sharedExternalTypes); } } } @@ -113,13 +117,19 @@ private void EmitSingleFile(IMarkdownWriterFactory factory, EmitConfig config) /// File-system folder path for the namespace. /// The type definition to document. /// No-link type resolver for parameter type cells. + /// + /// Shared throw-away accumulator passed to all calls. + /// Never populated because generateLinks is false; reused across all members + /// to avoid allocating a new set per call. + /// private void WriteSingleFileTypeSections( IMarkdownWriter writer, int depth, string namespaceName, string namespaceFolderPath, TypeDefinition type, - TypeLinkResolver resolver) + TypeLinkResolver resolver, + SortedSet sharedExternalTypes) { writer.WriteHeading(depth + 2, StripArity(type.Name)); @@ -195,12 +205,13 @@ private void WriteSingleFileTypeSections( _model.XmlDocs, resolver, namespaceName, - namespaceFolderPath); + namespaceFolderPath, + sharedExternalTypes); } } // Recursively emit visible nested types - WriteSingleFileNestedTypes(writer, depth, namespaceName, namespaceFolderPath, type, resolver); + WriteSingleFileNestedTypes(writer, depth, namespaceName, namespaceFolderPath, type, resolver, sharedExternalTypes); } /// @@ -215,6 +226,11 @@ private void WriteSingleFileTypeSections( /// No-link type resolver for parameter type cells. /// Fully qualified namespace name for signature simplification. /// File-system folder path for the namespace. + /// + /// Shared throw-away accumulator passed to all calls. + /// Never populated because generateLinks is false; reused across all parameters + /// and members to avoid allocating a new set per Linkify call. + /// private static void WriteSingleFileMemberSection( IMarkdownWriter writer, int depth, @@ -223,7 +239,8 @@ private static void WriteSingleFileMemberSection( XmlDocReader xmlDocs, TypeLinkResolver resolver, string namespaceName, - string namespaceFolderPath) + string namespaceFolderPath, + SortedSet sharedExternalTypes) { var displayName = GetMemberDisplayName(member); writer.WriteHeading(depth + 3, displayName); @@ -241,11 +258,12 @@ private static void WriteSingleFileMemberSection( { var paramDocs = xmlDocs.GetParams(memberId); var paramHeaders = new[] { "Parameter", "Type", DotNetEmitter.DescriptionColumnHeader }; - var externalTypes = new SortedSet(); var paramRows = method.Parameters.Select(p => { var desc = paramDocs.FirstOrDefault(pd => pd.Name == p.Name).Description ?? NoDescriptionPlaceholder; - var typeName = resolver.Linkify(p.ParameterType, namespaceFolderPath, namespaceName, externalTypes); + var typeName = resolver.Linkify(p.ParameterType, namespaceFolderPath, namespaceName, + // Shared throw-away accumulator — generateLinks is false so it is never populated or read + sharedExternalTypes); return new[] { p.Name, typeName, desc }; }); writer.WriteTable(paramHeaders, paramRows); @@ -291,17 +309,22 @@ private static void WriteSingleFileMemberSection( /// File-system folder path for the namespace. /// The containing type whose nested types are to be emitted. /// No-link type resolver for parameter type cells. + /// + /// Shared throw-away accumulator threaded through all + /// calls for nested type members. + /// private void WriteSingleFileNestedTypes( IMarkdownWriter writer, int depth, string namespaceName, string namespaceFolderPath, TypeDefinition type, - TypeLinkResolver resolver) + TypeLinkResolver resolver, + SortedSet sharedExternalTypes) { foreach (var nested in _emitter.GetVisibleNestedTypes(type)) { - WriteSingleFileTypeSections(writer, depth, namespaceName, namespaceFolderPath, nested, resolver); + WriteSingleFileTypeSections(writer, depth, namespaceName, namespaceFolderPath, nested, resolver, sharedExternalTypes); } } } diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 9c38652..67a1d18 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -23,6 +23,7 @@ public sealed class DotNetGenerator : IApiGenerator /// The generator configuration options. public DotNetGenerator(DotNetGeneratorOptions options) { + ArgumentNullException.ThrowIfNull(options); _options = options; } @@ -37,8 +38,8 @@ public DotNetGenerator(DotNetGeneratorOptions options) /// remains open until /// completes and is then disposed. /// - /// The entrypoint api.md lists only root namespaces followed by a file naming - /// and path convention appendix. Each namespace page lists only its immediate child + /// The entrypoint api.md lists all namespaces — both root and child — with a + /// direct type count column, followed by a file naming and path convention appendix. Each namespace page lists only its immediate child /// namespaces and types, enabling gradual disclosure for AI consumers. Namespace-level /// documentation is sourced from the NamespaceDoc convention: an /// internal static class NamespaceDoc in a namespace carries the namespace @@ -46,23 +47,32 @@ public DotNetGenerator(DotNetGeneratorOptions options) /// /// /// - /// Output channel for informational and error messages. Must not be null. Reserved for - /// future use — DotNetGenerator does not currently emit messages through this channel. + /// Output channel for informational messages. DotNetGenerator emits parsing progress and + /// type-count summary messages via . Must not be null. /// /// /// An holding all data required to emit documentation /// in any supported . The caller must subsequently /// invoke to write output. /// - /// Thrown when the XML documentation file does not exist. + /// Thrown when the assembly file or XML documentation file does not exist. public IApiEmitter Parse(IContext context) { + ArgumentNullException.ThrowIfNull(context); + + // Fail early if the assembly is absent to give the caller an actionable exception + if (!File.Exists(_options.AssemblyPath)) + { + throw new FileNotFoundException("Assembly file not found.", _options.AssemblyPath); + } + // Fail early if the XML doc is absent rather than producing empty output if (!File.Exists(_options.XmlDocPath)) { throw new FileNotFoundException("XML documentation file not found.", _options.XmlDocPath); } + context.WriteLine($"Parsing assembly: {Path.GetFileName(_options.AssemblyPath)}"); var assembly = AssemblyDefinition.ReadAssembly(_options.AssemblyPath); try { @@ -96,7 +106,7 @@ public IApiEmitter Parse(IContext context) .Where(t => !t.IsNested && _options.Visibility switch { ApiVisibility.Public => t.IsPublic, - ApiVisibility.PublicAndProtected => t.IsPublic || t.IsNestedFamily || t.IsNestedFamilyOrAssembly, + ApiVisibility.PublicAndProtected => t.IsPublic, ApiVisibility.All => true, _ => t.IsPublic, }) @@ -107,9 +117,10 @@ public IApiEmitter Parse(IContext context) var byNamespace = visibleTypes .GroupBy(t => t.Namespace) .OrderBy(g => g.Key) - .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name).ToList()); + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.OrderBy(t => t.Name).ToList()); var allNamespaces = byNamespace.Keys.OrderBy(n => n).ToList(); + context.WriteLine($"Found {visibleTypes.Count} types across {allNamespaces.Count} namespace(s)."); // Root namespaces: those not prefixed by any other namespace present in the assembly var rootNamespaces = allNamespaces @@ -520,3 +531,35 @@ private static string BuildMethodIdFromReference(MethodReference methodRef) return $"E:{typeName}.{eventName}"; } } + +/// Specifies which members are included in the generated API documentation. +public enum ApiVisibility +{ + /// Include only public members. + Public, + + /// Include public and protected members. + PublicAndProtected, + + /// Include all members regardless of access modifier. + All, +} + +/// Configuration options for . +public sealed class DotNetGeneratorOptions +{ + /// Gets or sets the path to the .NET assembly to document. + public string AssemblyPath { get; set; } = string.Empty; + + /// + /// Gets or sets the path to the XML documentation file produced alongside the assembly. + /// throws if this file does not exist. + /// + public string XmlDocPath { get; set; } = string.Empty; + + /// Gets or sets which members are visible in the generated output. Defaults to . + public ApiVisibility Visibility { get; set; } = ApiVisibility.Public; + + /// Gets or sets a value indicating whether obsolete members are included. Defaults to false. + public bool IncludeObsolete { get; set; } +} diff --git a/src/ApiMark.DotNet/DotNetGeneratorOptions.cs b/src/ApiMark.DotNet/DotNetGeneratorOptions.cs deleted file mode 100644 index 764bf41..0000000 --- a/src/ApiMark.DotNet/DotNetGeneratorOptions.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ApiMark.DotNet; - -/// Configuration options for . -public sealed class DotNetGeneratorOptions -{ - /// Gets or sets the path to the .NET assembly to document. - public string AssemblyPath { get; set; } = string.Empty; - - /// - /// Gets or sets the path to the XML documentation file produced alongside the assembly. - /// throws if this file does not exist. - /// - public string XmlDocPath { get; set; } = string.Empty; - - /// Gets or sets which members are visible in the generated output. Defaults to . - public ApiVisibility Visibility { get; set; } = ApiVisibility.Public; - - /// Gets or sets a value indicating whether obsolete members are included. Defaults to false. - public bool IncludeObsolete { get; set; } -} diff --git a/src/ApiMark.DotNet/ExternalTypeInfo.cs b/src/ApiMark.DotNet/ExternalTypeInfo.cs deleted file mode 100644 index 9a8e3e1..0000000 --- a/src/ApiMark.DotNet/ExternalTypeInfo.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) DemaConsulting LLC. All rights reserved. -// Licensed under the MIT License. - -namespace ApiMark.DotNet; - -/// -/// Represents a non-standard external type reference found in the documentation. -/// -/// -/// Instances are collected per generated Markdown file and emitted in the -/// "External Types" section so that readers know which additional packages or -/// headers they must include to use the documented API. System-namespace types -/// and C# primitives are excluded; only types whose namespace does not start -/// with "System" and are not CLR value-type primitives are recorded here. -/// -/// The simplified type name as it appears in the documentation table. -/// The .NET namespace that declares the type. -internal sealed record ExternalTypeInfo(string SimplifiedName, string Namespace) - : IComparable -{ - /// - /// Compares this instance to by simplified name, - /// enabling deterministic alphabetical sorting of the External Types table. - /// - /// The other instance to compare against, or . - /// - /// A negative value when this instance sorts before , - /// zero when equal, or a positive value when it sorts after. - /// - public int CompareTo(ExternalTypeInfo? other) - { - var nameComparison = StringComparer.Ordinal.Compare(SimplifiedName, other?.SimplifiedName); - return nameComparison != 0 - ? nameComparison - : StringComparer.Ordinal.Compare(Namespace, other?.Namespace); - } - - /// Returns when sorts before . - public static bool operator <(ExternalTypeInfo left, ExternalTypeInfo right) => - left.CompareTo(right) < 0; - - /// Returns when sorts before or equal to . - public static bool operator <=(ExternalTypeInfo left, ExternalTypeInfo right) => - left.CompareTo(right) <= 0; - - /// Returns when sorts after . - public static bool operator >(ExternalTypeInfo left, ExternalTypeInfo right) => - left.CompareTo(right) > 0; - - /// Returns when sorts after or equal to . - public static bool operator >=(ExternalTypeInfo left, ExternalTypeInfo right) => - left.CompareTo(right) >= 0; -} diff --git a/src/ApiMark.DotNet/TypeLinkResolver.cs b/src/ApiMark.DotNet/TypeLinkResolver.cs index 40d39d2..127b2fe 100644 --- a/src/ApiMark.DotNet/TypeLinkResolver.cs +++ b/src/ApiMark.DotNet/TypeLinkResolver.cs @@ -92,7 +92,7 @@ public TypeLinkResolver(IReadOnlyList rootNamespaces, bool generateLinks /// type, plain text when it is a primitive or System type, or plain text with external /// tracking when it is a non-System type from another assembly. /// - /// The Mono.Cecil type reference to resolve. Must not be null. + /// The Mono.Cecil type reference to resolve. When , an empty string is returned. /// /// The folder path of the Markdown file that will contain the link, relative to the /// documentation output root (e.g. ApiMark.DotNet.Fixtures/SampleClass). @@ -146,11 +146,12 @@ public string Linkify( return Linkify(inner, currentFolder, contextNamespace, externalTypes, true); } - // Handle array types by resolving the element type and appending "[]" (plus "?" when nullable) + // Handle array types by resolving the element type and appending the rank-aware suffix if (typeRef is ArrayType arrayType) { var elementText = Linkify(arrayType.ElementType, currentFolder, contextNamespace, externalTypes); - return isNullableAnnotated ? elementText + "[]?" : elementText + "[]"; + var suffix = "[" + new string(',', arrayType.Rank - 1) + "]"; + return isNullableAnnotated ? elementText + suffix + "?" : elementText + suffix; } // Handle generic instance types: linkify the container when intra-assembly, else track external @@ -323,3 +324,52 @@ private static void TrackExternalType(TypeReference typeRef, ISet +/// Represents a non-standard external type reference found in the documentation. +/// +/// +/// Instances are collected per generated Markdown file and emitted in the +/// "External Types" section so that readers know which additional packages or +/// headers they must include to use the documented API. System-namespace types +/// and C# primitives are excluded; only types whose namespace does not start +/// with "System" and are not CLR value-type primitives are recorded here. +/// +/// The simplified type name as it appears in the documentation table. +/// The .NET namespace that declares the type. +internal sealed record ExternalTypeInfo(string SimplifiedName, string Namespace) + : IComparable +{ + /// + /// Compares this instance to by simplified name, + /// enabling deterministic alphabetical sorting of the External Types table. + /// + /// The other instance to compare against, or . + /// + /// A negative value when this instance sorts before , + /// zero when equal, or a positive value when it sorts after. + /// + public int CompareTo(ExternalTypeInfo? other) + { + var nameComparison = StringComparer.Ordinal.Compare(SimplifiedName, other?.SimplifiedName); + return nameComparison != 0 + ? nameComparison + : StringComparer.Ordinal.Compare(Namespace, other?.Namespace); + } + + /// Returns when sorts before . + public static bool operator <(ExternalTypeInfo left, ExternalTypeInfo right) => + left.CompareTo(right) < 0; + + /// Returns when sorts before or equal to . + public static bool operator <=(ExternalTypeInfo left, ExternalTypeInfo right) => + left.CompareTo(right) <= 0; + + /// Returns when sorts after . + public static bool operator >(ExternalTypeInfo left, ExternalTypeInfo right) => + left.CompareTo(right) > 0; + + /// Returns when sorts after or equal to . + public static bool operator >=(ExternalTypeInfo left, ExternalTypeInfo right) => + left.CompareTo(right) >= 0; +} diff --git a/src/ApiMark.DotNet/TypeNameSimplifier.cs b/src/ApiMark.DotNet/TypeNameSimplifier.cs index eef9b08..cf6d496 100644 --- a/src/ApiMark.DotNet/TypeNameSimplifier.cs +++ b/src/ApiMark.DotNet/TypeNameSimplifier.cs @@ -72,6 +72,14 @@ public static string Simplify(TypeReference typeRef, string contextNamespace, bo } /// Applies Rules 1–6 to produce a simplified type name without nullable-reference annotation. + /// + /// Exists as a named helper so that can apply Rule 7 (nullable reference + /// annotation) as a single post-processing step without duplicating the core switch logic. + /// The switch-expression arms are ordered by structural specificity: composite types (array, + /// nullable generic, generic instance) are tested before primitive aliases and plain types to + /// ensure that, for example, int? (Nullable<int>) is handled by Rule 3 rather than + /// the primitive-alias arm. + /// /// The Mono.Cecil type reference to simplify. /// The namespace of the enclosing type, used for prefix stripping. /// A simplified C# type name without a trailing nullable-reference ?. @@ -79,9 +87,9 @@ private static string SimplifyCore(TypeReference typeRef, string contextNamespac { return typeRef switch { - // Rule 2: array types recurse on the element type + // Rule 2: array types recurse on the element type; rank-aware suffix (e.g., [] for 1-D, [,] for 2-D) ArrayType arr - => Simplify(arr.ElementType, contextNamespace) + "[]", + => Simplify(arr.ElementType, contextNamespace) + "[" + new string(',', arr.Rank - 1) + "]", // Rule 3: Nullable is represented as T? GenericInstanceType { GenericArguments.Count: 1 } git @@ -108,6 +116,14 @@ _ when Primitives.TryGetValue(typeRef.FullName, out var alias) } /// Builds the simplified type name for a generic type instance. + /// + /// Extracted from because generic instance handling requires + /// three distinct sub-decisions: (1) whether to strip the well-known namespace prefix + /// from the container type, (2) whether to apply context-namespace prefix stripping as + /// a fallback, and (3) recursive simplification of every type argument. Isolating these + /// steps in a named helper keeps readable and avoids a deeply + /// nested conditional inside the switch expression. + /// /// The generic instance type to represent. /// The namespace of the enclosing type, used for prefix stripping. /// A string of the form Name<Arg1, Arg2>. @@ -122,6 +138,13 @@ private static string BuildGenericName(GenericInstanceType git, string contextNa } /// Removes the generic arity suffix (e.g. `1) from a type name. + /// + /// Used wherever the arity count carries no meaning for human readers — for example, in + /// display names, external-type table entries, and type-page headings. The backtick and + /// following digit(s) are dropped entirely. Contrast with , + /// which removes only the backtick but preserves the digit so that file-system path + /// segments remain unique even when both Foo and Foo<T> exist. + /// /// The raw type name that may contain a backtick arity suffix. /// The name without the arity suffix. internal static string StripArity(string name) @@ -151,6 +174,13 @@ internal static string FlattenArity(string name) /// Returns the shortest unambiguous name for relative to /// , stripping that namespace's prefix if present. /// + /// + /// Exists as a named helper to encapsulate the context-stripping logic that is shared + /// between the plain-type arm of and the generic-container + /// name computation in . Returning the raw type name when + /// no prefix matches is the safe fallback — it avoids hiding a type behind an incorrect + /// short name when the namespace relationship is ambiguous. + /// /// The type reference to shorten. /// The namespace of the enclosing type. /// The shortest name that is unambiguous in the given context. diff --git a/src/ApiMark.DotNet/XmlDocReader.cs b/src/ApiMark.DotNet/XmlDocReader.cs index 06f0efb..f406d7d 100644 --- a/src/ApiMark.DotNet/XmlDocReader.cs +++ b/src/ApiMark.DotNet/XmlDocReader.cs @@ -5,6 +5,10 @@ namespace ApiMark.DotNet; /// Reads and indexes a .NET XML documentation file for fast member-level lookups. +/// +/// Safe for concurrent reads after construction; all fields are set once during the constructor +/// and are never subsequently mutated. +/// public sealed class XmlDocReader { /// Index of documentation members keyed by their XML doc identifier. @@ -602,6 +606,7 @@ private static void AppendMarkdownCodeSpan(StringBuilder builder, string content } /// + /// Returns the display text for a <see> or <seealso> /// element, preferring the langword attribute, then explicit element text, then a /// formatted cref attribute, and finally an empty string when none are present. /// @@ -718,7 +723,7 @@ private static string FormatTypeName(string typeName) "System.UInt32" => "uint", "System.UInt64" => "ulong", "System.Void" => "void", - _ => StripArity(typeName[(typeName.LastIndexOf('.') + 1)..]), + _ => FormatTypeArity(typeName[(typeName.LastIndexOf('.') + 1)..]), }; } @@ -733,10 +738,12 @@ private static string FormatTypeName(string typeName) private static bool IsPrimitiveTypeName(string typeName) => typeName.StartsWith("System.", StringComparison.Ordinal); /// - /// Removes the generic arity backtick suffix from a type name, e.g. converting List`1 - /// to List. Returns the original string unchanged when no backtick is present. + /// Removes the generic arity backtick suffix from a member name, e.g. converting + /// GetValue`1 to GetValue. Returns the original string unchanged when + /// no backtick is present. Use this for member name stripping only — for type display + /// names, use instead to produce angle-bracket notation. /// - /// Raw type name that may contain a backtick arity suffix. + /// Raw member name that may contain a backtick arity suffix. /// The name without the backtick and trailing digit(s). private static string StripArity(string typeName) { @@ -744,6 +751,35 @@ private static string StripArity(string typeName) return tickIndex >= 0 ? typeName[..tickIndex] : typeName; } + /// + /// Converts a raw CLR type name with a generic arity backtick suffix into a + /// C# display form with angle-bracket type-parameter placeholders. + /// For example, List`1List<T> and + /// Dictionary`2Dictionary<T1, T2>. + /// Returns the original string unchanged when no backtick is present. + /// + /// Raw type name that may contain a backtick arity suffix. + /// The name with angle-bracket placeholder notation, or the original name if no arity marker is present. + private static string FormatTypeArity(string typeName) + { + var tickIndex = typeName.IndexOf('`'); + if (tickIndex < 0) + { + return typeName; + } + + var baseName = typeName[..tickIndex]; + if (!int.TryParse(typeName[(tickIndex + 1)..], out var arity) || arity <= 0) + { + return baseName; + } + + var typeParams = arity == 1 + ? "T" + : string.Join(", ", Enumerable.Range(1, arity).Select(i => $"T{i}")); + return $"{baseName}<{typeParams}>"; + } + /// /// Normalizes raw documentation text by collapsing runs of internal whitespace on each /// line, trimming every line, and preserving non-empty lines separated by newlines. diff --git a/test/ApiMark.DotNet.Fixtures/AbstractFixtureClass.cs b/test/ApiMark.DotNet.Fixtures/AbstractFixtureClass.cs new file mode 100644 index 0000000..0646208 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/AbstractFixtureClass.cs @@ -0,0 +1,20 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.DotNet.Fixtures; + +/// Abstract fixture class for testing the abstract modifier in type signatures. +/// +/// Used by DotNetEmitter_BuildTypeSignature_AbstractClass_ContainsAbstractModifier to verify +/// that BuildTypeSignature emits the abstract keyword for abstract (non-sealed) classes. +/// +public abstract class AbstractFixtureClass +{ + /// Abstract method that derived classes must implement. + /// The input value to process. + /// A processed result string. + public abstract string Process(int value); + + /// Gets or sets a protected value used to verify property accessor signature rendering. + protected int ProtectedProperty { get; set; } +} diff --git a/test/ApiMark.DotNet.Fixtures/CaseCollisionClass.cs b/test/ApiMark.DotNet.Fixtures/CaseCollisionClass.cs index aebc276..d0d5672 100644 --- a/test/ApiMark.DotNet.Fixtures/CaseCollisionClass.cs +++ b/test/ApiMark.DotNet.Fixtures/CaseCollisionClass.cs @@ -1,3 +1,6 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + namespace ApiMark.DotNet.Fixtures; /// A class demonstrating case-insensitive member name collision. diff --git a/test/ApiMark.DotNet.Fixtures/InitPropertyClass.cs b/test/ApiMark.DotNet.Fixtures/InitPropertyClass.cs new file mode 100644 index 0000000..742966f --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/InitPropertyClass.cs @@ -0,0 +1,15 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.DotNet.Fixtures; + +/// A class with an init-only property for testing C# 9 init accessor detection. +/// +/// Used by DotNetEmitter_BuildPropertyAccessors_InitOnlySetter_EmitsInit to verify +/// that BuildPropertyAccessors emits the init keyword for init-only setters. +/// +public class InitPropertyClass +{ + /// Gets or initializes the name. + public string InitOnlyProperty { get; init; } = string.Empty; +} diff --git a/test/ApiMark.DotNet.Fixtures/IntVsIntArrayClass.cs b/test/ApiMark.DotNet.Fixtures/IntVsIntArrayClass.cs index e9bd2a8..9ac923c 100644 --- a/test/ApiMark.DotNet.Fixtures/IntVsIntArrayClass.cs +++ b/test/ApiMark.DotNet.Fixtures/IntVsIntArrayClass.cs @@ -1,3 +1,6 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + namespace ApiMark.DotNet.Fixtures; /// A class with methods overloaded by scalar vs array parameter types. diff --git a/test/ApiMark.DotNet.Fixtures/IsExternalInit.cs b/test/ApiMark.DotNet.Fixtures/IsExternalInit.cs new file mode 100644 index 0000000..c0b0af2 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/IsExternalInit.cs @@ -0,0 +1,18 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +// Polyfill required to use C# 9+ init-only setters when targeting netstandard2.0. +// The compiler emits modreq(IsExternalInit) for init accessors; this declaration +// satisfies the compiler for legacy targets. + +// ReSharper disable CheckNamespace +#pragma warning disable SA1403 // File may only contain a single namespace +#pragma warning disable SA1649 // File name should match first type name + +namespace System.Runtime.CompilerServices; + +/// Polyfill marker type that allows C# 9+ init-only property setters on netstandard2.0 targets. +internal static class IsExternalInit { } + +#pragma warning restore SA1649 +#pragma warning restore SA1403 diff --git a/test/ApiMark.DotNet.Fixtures/OperatorsStruct.cs b/test/ApiMark.DotNet.Fixtures/OperatorsStruct.cs index 77dc5f7..5b92434 100644 --- a/test/ApiMark.DotNet.Fixtures/OperatorsStruct.cs +++ b/test/ApiMark.DotNet.Fixtures/OperatorsStruct.cs @@ -1,3 +1,6 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + namespace ApiMark.DotNet.Fixtures; /// A struct with operator overloads for testing operator page generation. diff --git a/test/ApiMark.DotNet.Fixtures/OuterClass.cs b/test/ApiMark.DotNet.Fixtures/OuterClass.cs index 9caa3fa..c9eaad8 100644 --- a/test/ApiMark.DotNet.Fixtures/OuterClass.cs +++ b/test/ApiMark.DotNet.Fixtures/OuterClass.cs @@ -1,3 +1,6 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + namespace ApiMark.DotNet.Fixtures; /// An outer class containing a public nested type for testing nested type page generation. diff --git a/test/ApiMark.DotNet.Fixtures/SampleClass.cs b/test/ApiMark.DotNet.Fixtures/SampleClass.cs index 3483302..ee2e404 100644 --- a/test/ApiMark.DotNet.Fixtures/SampleClass.cs +++ b/test/ApiMark.DotNet.Fixtures/SampleClass.cs @@ -1,3 +1,6 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + namespace ApiMark.DotNet.Fixtures; /// A sample class for testing the API generator. diff --git a/test/ApiMark.DotNet.Fixtures/SampleEnum.cs b/test/ApiMark.DotNet.Fixtures/SampleEnum.cs index fb5c52b..a50c21c 100644 --- a/test/ApiMark.DotNet.Fixtures/SampleEnum.cs +++ b/test/ApiMark.DotNet.Fixtures/SampleEnum.cs @@ -1,3 +1,6 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + namespace ApiMark.DotNet.Fixtures; /// Represents the status of a sample item. diff --git a/test/ApiMark.DotNet.Fixtures/StaticFixtureClass.cs b/test/ApiMark.DotNet.Fixtures/StaticFixtureClass.cs new file mode 100644 index 0000000..7e14328 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/StaticFixtureClass.cs @@ -0,0 +1,14 @@ +namespace ApiMark.DotNet.Fixtures; + +/// Static fixture class for testing the static modifier in generated signatures. +/// +/// Used by DotNetEmitter_BuildTypeSignature_StaticClass_ContainsStaticModifier to verify +/// that BuildTypeSignature emits the static keyword for static classes. +/// +public static class StaticFixtureClass +{ + /// Returns the formatted value as a string. + /// The value to format. + /// A formatted string representation of the value. + public static string Format(int value) => $"StaticFixtureClass({value})"; +} diff --git a/test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs b/test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs index 10ccd6e..eeebf00 100644 --- a/test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs @@ -51,7 +51,7 @@ public void DotNetAstModel_RootNamespaces_ContainsFixtureNamespace() var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); // Act / Assert - Assert.NotEmpty(emitter.Model.RootNamespaces); + Assert.Contains("ApiMark.DotNet.Fixtures", emitter.Model.RootNamespaces); } /// Validates that returns the same options instance passed at construction. @@ -80,7 +80,7 @@ public void DotNetAstModel_Assembly_ReturnsLoadedAssembly() /// Validates that is non-null. [Fact] - public void DotNetAstModel_Resolver_IsNotNull() + public void DotNetAstModel_Resolver_AfterParse_IsNotNull() { // Arrange var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); @@ -88,4 +88,44 @@ public void DotNetAstModel_Resolver_IsNotNull() // Act / Assert Assert.NotNull(emitter.Model.Resolver); } + + /// Validates that is non-null after construction. + [Fact] + public void DotNetAstModel_XmlDocs_AfterParse_IsNotNull() + { + // Arrange + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act / Assert + Assert.NotNull(emitter.Model.XmlDocs); + } + + /// Validates that is non-null after construction. + [Fact] + public void DotNetAstModel_NamespaceDescriptions_AfterParse_IsNotNull() + { + // Arrange + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act / Assert + Assert.NotNull(emitter.Model.NamespaceDescriptions); + } + + /// + /// Validates that the collection-type properties of satisfy the + /// read-only interface constraints at runtime — confirming the compiler's static guarantees + /// hold through the parse pipeline. + /// + [Fact] + public void DotNetAstModel_Collections_ExposeReadOnlyInterfaces() + { + // Arrange + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + var model = emitter.Model; + + // Assert: each collection-type property must satisfy IReadOnly* at runtime + Assert.IsAssignableFrom>(model.AllNamespaces); + Assert.IsAssignableFrom>>(model.ByNamespace); + Assert.IsAssignableFrom>(model.RootNamespaces); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs index f2297ac..c5039dc 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs @@ -68,6 +68,45 @@ public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesTypePage() "Expected a type page containing 'SampleClass'"); } + /// Validates that the gradual-disclosure emitter creates a dedicated detail page for at least one visible member. + [Fact] + public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMemberDetailPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: SampleClass has a Reset method — its detail page must exist + Assert.True( + factory.Writers.Keys.Any(k => + k.Contains("SampleClass", StringComparison.Ordinal) && + k.Contains("Reset", StringComparison.Ordinal)), + "Expected a member detail page for SampleClass.Reset"); + } + + /// Validates that the gradual-disclosure emitter creates a combined page for case-colliding members. + [Fact] + public void DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesCombinedPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: CaseCollisionClass has 'name' (field) and 'Name' (property) that collide on + // case-insensitive filesystems; the combined page is keyed using the lower-invariant "name" + Assert.True( + factory.Writers.Keys.Any(k => + k.Contains("CaseCollisionClass", StringComparison.Ordinal) && + k.EndsWith("/name", StringComparison.OrdinalIgnoreCase)), + "Expected a combined collision page for CaseCollisionClass members 'name' and 'Name'"); + } + /// Validates that the api index page heading contains the assembly name. [Fact] public void DotNetEmitterGradualDisclosure_Emit_ValidModel_ApiIndexContainsAssemblyNameHeading() @@ -82,6 +121,81 @@ public void DotNetEmitterGradualDisclosure_Emit_ValidModel_ApiIndexContainsAssem // Assert: api page heading contains assembly name text var apiWriter = factory.GetWriter("", "api"); var headings = apiWriter.Operations.OfType().ToList(); - Assert.Contains(headings, h => h.Text.Contains("Fixtures", StringComparison.Ordinal)); + Assert.Contains(headings, h => h.Text.Contains("ApiMark.DotNet.Fixtures API Reference", StringComparison.Ordinal)); + } + + /// Validates that a type with overloaded methods produces a consolidated overload page. + [Fact] + public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMethodOverloadPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: IntVsIntArrayClass has two overloads of Process() — they should share one page + Assert.True( + factory.Writers.Keys.Any(k => + k.Contains("IntVsIntArrayClass", StringComparison.Ordinal) && + k.EndsWith("/Process", StringComparison.OrdinalIgnoreCase)), + "Expected a consolidated overload page for IntVsIntArrayClass.Process"); + } + + /// Validates that a type with operator overloads produces an operators.md page. + [Fact] + public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesOperatorsPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: OperatorsStruct declares operator overloads — an operators.md page must be created + Assert.True( + factory.Writers.Keys.Any(k => + k.Contains("OperatorsStruct", StringComparison.Ordinal) && + k.EndsWith("/operators", StringComparison.OrdinalIgnoreCase)), + "Expected an operators page for OperatorsStruct"); + } + + /// Validates that a type with a nested type produces a dedicated page for the nested type. + [Fact] + public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesNestedTypePage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: OuterClass.Inner should produce a dedicated page under the OuterClass folder + Assert.True( + factory.Writers.Keys.Any(k => + k.Contains("OuterClass", StringComparison.Ordinal) && + k.Contains("Inner", StringComparison.Ordinal)), + "Expected a dedicated page for OuterClass.Inner nested type"); + } + + /// Validates that a child namespace also produces a dedicated Markdown page. + [Fact] + public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesChildNamespacePage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: the child namespace ApiMark.DotNet.Fixtures.Inner must produce its own page + Assert.True( + factory.Writers.Keys.Any(k => k.Contains("Inner", StringComparison.Ordinal) && + !k.Contains("OuterClass", StringComparison.Ordinal)), + "Expected a dedicated page for the ApiMark.DotNet.Fixtures.Inner child namespace"); } } diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs index 3d541a4..3f31356 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs @@ -99,4 +99,223 @@ public void DotNetEmitterSingleFile_Emit_ValidModel_ApiFileContainsTypeHeading() var headings = apiWriter.Operations.OfType().ToList(); Assert.Contains(headings, h => h.Text.Contains("SampleClass", StringComparison.Ordinal)); } + + /// Validates that all heading levels are offset by HeadingDepth when it is set to a non-default value. + [Fact] + public void DotNetEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings() + { + // Arrange: HeadingDepth = 2 means assembly → H2, namespace → H3, type → H4 + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + var config = new EmitConfig { Format = OutputFormat.SingleFile, HeadingDepth = 2 }; + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, config, new InMemoryContext()); + + // Assert: check heading levels by Level property + var apiWriter = factory.GetWriter("", "api"); + var headings = apiWriter.Operations.OfType().ToList(); + + // Assembly heading must be H2 + Assert.Contains(headings, h => h.Text.Contains("Fixtures", StringComparison.Ordinal) && h.Level == 2); + + // Namespace heading must be H3 + Assert.Contains(headings, h => h.Text.Contains("ApiMark.DotNet.Fixtures", StringComparison.Ordinal) && h.Level == 3); + + // Type heading must be H4 + Assert.Contains(headings, h => h.Text.Contains("SampleClass", StringComparison.Ordinal) && h.Level == 4); + } + + /// Validates that an AssemblyDescriptionAttribute value is emitted as a paragraph after the assembly-level heading. + [Fact] + public void DotNetEmitterSingleFile_Emit_AssemblyWithDescription_EmitsDescriptionParagraph() + { + // Arrange: the fixture assembly carries a Description property in its csproj + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: a paragraph containing the assembly description appears in the output + var apiWriter = factory.GetWriter("", "api"); + var paragraphs = apiWriter.Operations.OfType().ToList(); + Assert.Contains(paragraphs, p => p.Text.Contains("fixture", StringComparison.OrdinalIgnoreCase)); + } + + /// Validates that the NamespaceDoc XML summary is emitted as a paragraph following the namespace heading. + [Fact] + public void DotNetEmitterSingleFile_Emit_NamespaceWithDoc_EmitsNamespaceSummary() + { + // Arrange: the fixture namespace has an internal static NamespaceDoc class with a summary + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: a paragraph containing part of the NamespaceDoc summary appears in the output + var apiWriter = factory.GetWriter("", "api"); + var paragraphs = apiWriter.Operations.OfType().ToList(); + Assert.Contains(paragraphs, p => p.Text.Contains("testing the ApiMark", StringComparison.OrdinalIgnoreCase)); + } + + /// Validates that a compact bullet list paragraph appears before per-member heading sections within a type section. + [Fact] + public void DotNetEmitterSingleFile_Emit_TypeWithMembers_EmitsBulletListBeforeMemberHeadings() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: a ParagraphOperation whose text starts with "- **" (the bullet list) appears before + // a HeadingOperation at depth+3 level that represents a member heading for SampleClass + var apiWriter = factory.GetWriter("", "api"); + var ops = apiWriter.Operations.ToList(); + + // Find the SampleClass heading index + var sampleClassHeadingIdx = ops + .Select((op, i) => (op, i)) + .First(x => x.op is HeadingOperation h && h.Text.Contains("SampleClass", StringComparison.Ordinal)) + .i; + + // Find the bullet list paragraph after SampleClass heading + var bulletParagraphIdx = ops + .Select((op, i) => (op, i)) + .FirstOrDefault(x => x.i > sampleClassHeadingIdx && x.op is ParagraphOperation p && p.Text.StartsWith("- **", StringComparison.Ordinal)) + .i; + + // Find the first H4 member heading after SampleClass heading (depth=1 → members at H4) + var firstMemberHeadingIdx = ops + .Select((op, i) => (op, i)) + .FirstOrDefault(x => x.i > sampleClassHeadingIdx && x.op is HeadingOperation h && h.Level == 4) + .i; + + Assert.True(bulletParagraphIdx > 0, "A bullet-list paragraph must be present in the SampleClass section."); + Assert.True(firstMemberHeadingIdx > 0, "A member-level heading must be present in the SampleClass section."); + Assert.True(bulletParagraphIdx < firstMemberHeadingIdx, "The bullet-list paragraph must appear before the first member heading."); + } + + /// Validates that constructor members appear before other members in single-file output. + [Fact] + public void DotNetEmitterSingleFile_Emit_TypeWithConstructorAndMethods_ConstructorAppearsFirst() + { + // Arrange: OuterClass has both a constructor and a property; constructor must appear first + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: within the OuterClass section, find all H4 member headings and confirm the + // constructor heading (".ctor" / "OuterClass") appears before the property heading ("Value") + var apiWriter = factory.GetWriter("", "api"); + var ops = apiWriter.Operations.ToList(); + + var outerClassIdx = ops + .Select((op, i) => (op, i)) + .First(x => x.op is HeadingOperation h && h.Text == "OuterClass") + .i; + + // Find H4 headings after OuterClass that mention constructor-related text and property text + var h4HeadingsAfterOuter = ops + .Select((op, i) => (op, i)) + .Where(x => x.i > outerClassIdx && x.op is HeadingOperation h && h.Level == 4) + .Select(x => (idx: x.i, text: ((HeadingOperation)x.op).Text)) + .ToList(); + + var ctorIdx = h4HeadingsAfterOuter.FirstOrDefault(x => x.text.Contains("OuterClass(", StringComparison.Ordinal) || x.text == "OuterClass()").idx; + var valueIdx = h4HeadingsAfterOuter.FirstOrDefault(x => x.text == "Value").idx; + + Assert.True(ctorIdx > 0, "A constructor heading must be present in the OuterClass section."); + Assert.True(valueIdx > 0, "A Value property heading must be present in the OuterClass section."); + Assert.True(ctorIdx < valueIdx, "The constructor heading must appear before the Value property heading."); + } + + /// Validates that delegate types do not emit compiler-generated member sections. + [Fact] + public void DotNetEmitterSingleFile_Emit_DelegateType_NoMemberSectionsEmitted() + { + // Arrange: ServiceEvent is a public delegate in the fixture assembly + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: no heading containing "Invoke", "BeginInvoke", or "EndInvoke" appears after ServiceEvent + var apiWriter = factory.GetWriter("", "api"); + var ops = apiWriter.Operations.ToList(); + + var serviceEventIdx = ops + .Select((op, i) => (op, i)) + .First(x => x.op is HeadingOperation h && h.Text.Contains("ServiceEvent", StringComparison.Ordinal)) + .i; + + // Find the next type-level heading after ServiceEvent to bound our search + var nextTypeHeadingIdx = ops + .Select((op, i) => (op, i)) + .FirstOrDefault(x => x.i > serviceEventIdx && x.op is HeadingOperation h && h.Level == 3) + .i; + + var upperBound = nextTypeHeadingIdx > serviceEventIdx ? nextTypeHeadingIdx : ops.Count; + + var hasCompilerMember = ops + .Skip(serviceEventIdx + 1) + .Take(upperBound - serviceEventIdx - 1) + .Any(op => op is HeadingOperation h && + (h.Text.Contains("Invoke", StringComparison.Ordinal))); + + Assert.False(hasCompilerMember, "Delegate compiler-generated members (Invoke, BeginInvoke, EndInvoke) must not be emitted."); + } + + /// Validates that nested types have a parent-context notice paragraph indicating their containing type. + [Fact] + public void DotNetEmitterSingleFile_Emit_NestedType_EmitsParentNotice() + { + // Arrange: OuterClass.Inner is a nested type — it must carry a "Nested type of `OuterClass`." notice + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: a ParagraphOperation containing "Nested type of" and "OuterClass" appears in the output + var apiWriter = factory.GetWriter("", "api"); + var paragraphs = apiWriter.Operations.OfType().ToList(); + Assert.Contains(paragraphs, p => + p.Text.Contains("Nested type of", StringComparison.Ordinal) && + p.Text.Contains("OuterClass", StringComparison.Ordinal)); + } + + /// Validates that parameter type cells in single-file output are plain text, not Markdown links. + [Fact] + public void DotNetEmitterSingleFile_Emit_MethodWithParameter_TypeCellIsPlainText() + { + // Arrange: SampleClass.GetGreeting(string name) — the Type cell must be "string" not a Markdown link + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: find the parameter table for GetGreeting and verify the Type cell does not contain a Markdown link + var apiWriter = factory.GetWriter("", "api"); + var tables = apiWriter.Operations.OfType().ToList(); + + // Find a parameter table (headers: Parameter, Type, Description) whose first row Type cell is "string" + var paramTable = tables.FirstOrDefault(t => + t.Headers.Length == 3 && + t.Headers[0] == "Parameter" && + t.Headers[1] == "Type" && + t.Rows.Any(r => r[0] == "name" && r[1] == "string")); + + Assert.NotNull(paramTable); + var nameRow = paramTable.Rows.First(r => r[0] == "name"); + Assert.Equal("string", nameRow[1]); + Assert.DoesNotContain("[", nameRow[1], StringComparison.Ordinal); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs index 09ecc68..60e2157 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs @@ -4,6 +4,7 @@ using ApiMark.Core; using ApiMark.Core.TestHelpers; using ApiMark.DotNet; +using Mono.Cecil; using Xunit; namespace ApiMark.DotNet.Tests; @@ -30,6 +31,30 @@ public void DotNetEmitter_Emit_NullFactory_ThrowsArgumentNullException() Assert.Throws(() => emitter.Emit(null!, new EmitConfig(), new InMemoryContext())); } + /// Validates that passing a null config to throws . + [Fact] + public void DotNetEmitter_Emit_NullConfig_ThrowsArgumentNullException() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act / Assert + Assert.Throws(() => emitter.Emit(factory, null!, new InMemoryContext())); + } + + /// Validates that passing a null context to throws . + [Fact] + public void DotNetEmitter_Emit_NullContext_ThrowsArgumentNullException() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act / Assert + Assert.Throws(() => emitter.Emit(factory, new EmitConfig(), null!)); + } + /// Validates that produces more than one writer. [Fact] public void DotNetEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles() @@ -104,4 +129,144 @@ public void DotNetEmitter_ToXmlDocTypeName_ConvertsGenericNotation(string cecilF // Assert Assert.Equal(expected, result); } + + /// Validates that returns the full name for an unknown namespace. + [Fact] + public void DotNetEmitter_GetNamespaceFolderPath_UnknownNamespace_ReturnsFullName() + { + // Arrange / Act + var result = DotNetEmitter.GetNamespaceFolderPath("Unknown.Namespace", ["ApiMark.DotNet"]); + + // Assert: namespace that matches no root is returned as its full dotted name + Assert.Equal("Unknown.Namespace", result); + } + + /// + /// Validates that includes the abstract + /// modifier for an abstract class that is not sealed. + /// + [Fact] + public void DotNetEmitter_BuildTypeSignature_AbstractClass_ContainsAbstractModifier() + { + // Arrange: load the abstract fixture class from the fixture assembly + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var type = assembly.MainModule.Types.First(t => t.Name == "AbstractFixtureClass"); + + // Act + var signature = DotNetEmitter.BuildTypeSignature(type, "ApiMark.DotNet.Fixtures"); + + // Assert: the abstract modifier must be present and no conflicting modifiers appear + Assert.Contains("abstract ", signature, StringComparison.Ordinal); + Assert.DoesNotContain("static ", signature, StringComparison.Ordinal); + Assert.DoesNotContain("sealed ", signature, StringComparison.Ordinal); + } + + /// + /// Validates that includes the sealed + /// modifier for a sealed class that is not abstract. + /// + [Fact] + public void DotNetEmitter_BuildTypeSignature_SealedClass_ContainsSealedModifier() + { + // Arrange: load the sealed fixture class from the fixture assembly + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var type = assembly.MainModule.Types.First(t => t.Name == "SealedClass"); + + // Act + var signature = DotNetEmitter.BuildTypeSignature(type, "ApiMark.DotNet.Fixtures"); + + // Assert: the sealed modifier must be present and no conflicting modifiers appear + Assert.Contains("sealed ", signature, StringComparison.Ordinal); + Assert.DoesNotContain("abstract ", signature, StringComparison.Ordinal); + Assert.DoesNotContain("static ", signature, StringComparison.Ordinal); + } + + /// + /// Validates that includes the static + /// modifier for a static class. + /// + [Fact] + public void DotNetEmitter_BuildTypeSignature_StaticClass_ContainsStaticModifier() + { + // Arrange: load the static fixture class from the fixture assembly + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var type = assembly.MainModule.Types.First(t => t.Name == "StaticFixtureClass"); + + // Act + var signature = DotNetEmitter.BuildTypeSignature(type, "ApiMark.DotNet.Fixtures"); + + // Assert: the static modifier must be present and no conflicting modifiers appear + Assert.Contains("static ", signature, StringComparison.Ordinal); + Assert.DoesNotContain("abstract ", signature, StringComparison.Ordinal); + Assert.DoesNotContain("sealed ", signature, StringComparison.Ordinal); + } + + /// + /// Validates that returns + /// for the NamespaceDoc carrier class in the fixture assembly. + /// + [Fact] + public void DotNetEmitter_IsNamespaceDocCarrier_NamespaceDocClass_ReturnsTrue() + { + // Arrange: load all types including internal ones from the fixture assembly + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var type = assembly.MainModule.Types.First(t => t.Name == "NamespaceDoc"); + + // Act + var result = DotNetEmitter.IsNamespaceDocCarrier(type); + + // Assert + Assert.True(result, "NamespaceDoc internal static class must be recognized as a carrier."); + } + + /// + /// Validates that returns + /// for a regular (non-carrier) class. + /// + [Fact] + public void DotNetEmitter_IsNamespaceDocCarrier_RegularClass_ReturnsFalse() + { + // Arrange: SampleClass is a regular public class, not a NamespaceDoc carrier + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var type = assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + + // Act + var result = DotNetEmitter.IsNamespaceDocCarrier(type); + + // Assert + Assert.False(result, "A regular public class must not be recognized as a NamespaceDoc carrier."); + } + + /// Validates that emits init; for init-only setters. + [Fact] + public void DotNetEmitter_BuildPropertyAccessors_InitOnlySetter_EmitsInit() + { + // Arrange + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var type = assembly.MainModule.Types.Single(t => t.Name == "InitPropertyClass"); + var prop = type.Properties.Single(p => p.Name == "InitOnlyProperty"); + + // Act + var result = DotNetEmitter.BuildPropertyAccessors(prop); + + // Assert + Assert.Contains("init", result, StringComparison.Ordinal); + Assert.DoesNotContain("set", result, StringComparison.Ordinal); + } + + /// Validates that does not prefix accessors when they share the property's declared accessibility. + [Fact] + public void DotNetEmitter_BuildPropertyAccessors_ProtectedProperty_DoesNotPrefixAccessors() + { + // Arrange: load ProtectedProperty from AbstractFixtureClass in the fixture assembly + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var type = assembly.MainModule.Types.First(t => t.Name == "AbstractFixtureClass"); + var prop = type.Properties.Single(p => p.Name == "ProtectedProperty"); + + // Act + var result = DotNetEmitter.BuildPropertyAccessors(prop); + + // Assert: both accessors share the property's protected accessibility — no prefix should be emitted + Assert.Equal("get; set;", result); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index 15f401b..e3c98b5 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1073,6 +1073,25 @@ public void DotNetGenerator_NamespacePage_NamespaceDocClass_ExcludedFromTypeList p => p.Contains("Contains types for testing", StringComparison.Ordinal)); } + /// Validates that the NamespaceDoc XML summary appears as a paragraph on the namespace page. + [Fact] + public void DotNetGenerator_NamespacePage_NamespaceDocSummary_AppearsAsNamespaceDescription() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: the NamespaceDoc must appear as a paragraph on the namespace page + var nsWriter = factory.Writers["ApiMark.DotNet.Fixtures"]; + var paragraphs = nsWriter.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains( + paragraphs, + p => p.Contains("Contains types for testing", StringComparison.Ordinal)); + } + /// /// Validates that two members whose names differ only in case are combined onto a /// single page named after the lowercase key. @@ -2038,7 +2057,61 @@ public void DotNetGenerator_Generate_CrefInheritdoc_MultipleGenericParams_FullyQ Assert.Contains(paragraphs, p => p.Contains("Transforms data using the provided callback")); Assert.DoesNotContain(paragraphs, p => p.Contains("No description provided")); } -} + /// Validates that constructing with a null options argument throws . + [Fact] + public void DotNetGenerator_Constructor_NullOptions_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => new DotNetGenerator(null!)); + } + + /// Validates that throws when the assembly path does not exist. + [Fact] + public void DotNetGenerator_Parse_MissingAssemblyPath_ThrowsFileNotFoundException() + { + // Arrange + var options = new DotNetGeneratorOptions + { + AssemblyPath = "/nonexistent/path/assembly.dll", + XmlDocPath = FixturePaths.GetFixtureXmlDoc(), + }; + var generator = new DotNetGenerator(options); + + // Act / Assert + Assert.Throws(() => generator.Parse(new InMemoryContext())); + } + /// Validates that an internal static NamespaceDoc class is excluded from the generated type listing. + [Fact] + public void DotNetGenerator_Generate_NamespaceDocClass_ExcludedFromTypeListing() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); + // Assert: the namespace type table must not contain a row for NamespaceDoc + var nsWriter = factory.Writers["ApiMark.DotNet.Fixtures"]; + var typeTable = nsWriter.Operations.OfType().FirstOrDefault(t => t.Headers[0] == "Type"); + if (typeTable != null) + { + Assert.DoesNotContain( + typeTable.Rows, + row => row[0].Contains("NamespaceDoc", StringComparison.Ordinal)); + } + } + + /// Validates that throws when context is null. + [Fact] + public void DotNetGenerator_Parse_NullContext_ThrowsArgumentNullException() + { + // Arrange + var generator = new DotNetGenerator(BuildOptions()); + + // Act / Assert + Assert.Throws(() => generator.Parse(null!)); + } +} diff --git a/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs b/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs index d43cd7c..7b227e2 100644 --- a/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs +++ b/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs @@ -117,4 +117,171 @@ public void TypeLinkResolver_Linkify_NullableGenericParameter_AppendsQuestionMar // Assert: the result must be "T?" — the plain parameter name plus the nullability marker Assert.Equal("T?", result); } + + /// + /// Validates that a non-System external type reference is rendered as plain text + /// and tracked in the caller-supplied external type set. + /// + [Fact] + public void TypeLinkResolver_Linkify_ExternalNonSystemType_ReturnsPlainNameAndTracksExternalType() + { + // Arrange: create a resolver and a synthetic external TypeReference whose namespace + // does not start with "System" and whose scope is an AssemblyNameReference (external) + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"]); + var externalTypes = new HashSet(); + var assemblyRef = new AssemblyNameReference("Acme.Widgets", new Version(1, 0)); + var externalTypeRef = new TypeReference("Acme.Widgets", "Widget", _assembly.MainModule, assemblyRef); + + // Act + var result = resolver.Linkify(externalTypeRef, "", "ApiMark.DotNet.Fixtures", externalTypes); + + // Assert: plain name returned and the type is recorded in the external types set + Assert.Multiple( + () => Assert.Equal("Widget", result), + () => Assert.Single(externalTypes), + () => Assert.Contains( + new ExternalTypeInfo("Widget", "Acme.Widgets"), + externalTypes)); + } + + /// + /// Validates that an array type reference renders with the [] array rank suffix + /// appended to the element type name. + /// + [Fact] + public void TypeLinkResolver_Linkify_ArrayType_AppendsArraySuffix() + { + // Arrange: wrap System.String in an ArrayType to represent string[] + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"]); + var externalTypes = new HashSet(); + var stringArrayType = new ArrayType(_assembly.MainModule.TypeSystem.String); + + // Act + var result = resolver.Linkify(stringArrayType, "", "ApiMark.DotNet.Fixtures", externalTypes); + + // Assert: the result is "string[]" — element alias plus array suffix + Assert.EndsWith("[]", result, StringComparison.Ordinal); + } + + /// + /// Validates that a generic instance type reference renders with angle-bracket notation + /// showing the resolved type arguments. + /// + [Fact] + public void TypeLinkResolver_Linkify_GenericType_RendersTypeArguments() + { + // Arrange: get the return type of ArrayAndNullableClass.GetList() which is List + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"]); + var externalTypes = new HashSet(); + var arrayClass = _assembly.MainModule.Types.First(t => t.Name == "ArrayAndNullableClass"); + var getListMethod = arrayClass.Methods.First(m => m.Name == "GetList"); + var genericReturnType = getListMethod.ReturnType; // List + + // Act + var result = resolver.Linkify(genericReturnType, "", "ApiMark.DotNet.Fixtures", externalTypes); + + // Assert: the result contains angle-bracket notation for the type arguments + Assert.Contains("\\<", result, StringComparison.Ordinal); + Assert.Contains("\\>", result, StringComparison.Ordinal); + } + + /// + /// Validates that a Nullable<int> generic instance is resolved to the + /// inner C# alias with a ? suffix (i.e., "int?"). + /// + [Fact] + public void TypeLinkResolver_Linkify_NullableValueType_ReturnsInnerAliasWithQuestionMark() + { + // Arrange: construct Nullable as a GenericInstanceType + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"]); + var externalTypes = new HashSet(); + var nullableType = new GenericInstanceType(_assembly.MainModule.ImportReference(typeof(Nullable<>))); + nullableType.GenericArguments.Add(_assembly.MainModule.TypeSystem.Int32); + + // Act + var result = resolver.Linkify(nullableType, "", "ApiMark.DotNet.Fixtures", externalTypes); + + // Assert: Nullable must render as "int?" — not "Nullable" + Assert.Equal("int?", result); + } + + /// + /// Validates that a type from a System sub-namespace (e.g. System.IO.Stream) is + /// returned as plain text and is not added to the external types accumulator. + /// + [Fact] + public void TypeLinkResolver_Linkify_SystemNamespaceExternalType_ReturnsPlainTextAndDoesNotTrack() + { + // Arrange: construct a synthetic TypeReference for System.IO.Stream from an external assembly scope + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"]); + var externalTypes = new HashSet(); + var assemblyRef = new AssemblyNameReference("System.Runtime", new Version(8, 0)); + var typeRef = new TypeReference("System.IO", "Stream", _assembly.MainModule, assemblyRef); + + // Act + var result = resolver.Linkify(typeRef, "", "ApiMark.DotNet.Fixtures", externalTypes); + + // Assert: plain name returned; System types are not tracked as external dependencies + Assert.Equal("Stream", result); + Assert.Empty(externalTypes); + } + + /// + /// Validates that a multi-dimensional array type reference renders with the correct + /// rank suffix appended to the element type name (e.g., [,] for rank-2). + /// + [Fact] + public void TypeLinkResolver_Linkify_MultiDimensionalArrayType_AppendsRankSuffix() + { + // Arrange: wrap System.Int32 in a rank-2 ArrayType to represent int[,] + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"]); + var externalTypes = new HashSet(); + var rank2ArrayType = new ArrayType(_assembly.MainModule.TypeSystem.Int32, 2); + + // Act + var result = resolver.Linkify(rank2ArrayType, "", "ApiMark.DotNet.Fixtures", externalTypes); + + // Assert: rank-2 int array must produce "int[,]" + Assert.Equal("int[,]", result); + } + + /// + /// Validates that a nullable intra-assembly type reference produces a Markdown link + /// with a ? suffix when is . + /// + [Fact] + public void TypeLinkResolver_Linkify_NullableIntraAssemblyType_ReturnsLinkWithQuestionMark() + { + // Arrange + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"], generateLinks: true); + var externalTypes = new HashSet(); + var typeDef = _assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + + // Act + var result = resolver.Linkify(typeDef, "ApiMark.DotNet.Fixtures", "ApiMark.DotNet.Fixtures", externalTypes, isNullableAnnotated: true); + + // Assert: the result must contain a Markdown link AND end with "?" + Assert.Contains("[", result, StringComparison.Ordinal); + Assert.EndsWith("?", result, StringComparison.Ordinal); + } + + /// + /// Validates that an array type reference with set to + /// produces a result ending with []? — the array rank suffix + /// followed by the nullable marker. + /// + [Fact] + public void TypeLinkResolver_Linkify_NullableArrayType_AppendsArraySuffixThenQuestionMark() + { + // Arrange: wrap System.String in an ArrayType to represent string[] + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"]); + var externalTypes = new HashSet(); + var stringArrayType = new ArrayType(_assembly.MainModule.TypeSystem.String); + + // Act + var result = resolver.Linkify(stringArrayType, "", "ApiMark.DotNet.Fixtures", externalTypes, isNullableAnnotated: true); + + // Assert: nullable string array must render as "string[]?" + Assert.EndsWith("[]?", result, StringComparison.Ordinal); + } } diff --git a/test/ApiMark.DotNet.Tests/TypeNameSimplifierTests.cs b/test/ApiMark.DotNet.Tests/TypeNameSimplifierTests.cs index b8a599e..c168aef 100644 --- a/test/ApiMark.DotNet.Tests/TypeNameSimplifierTests.cs +++ b/test/ApiMark.DotNet.Tests/TypeNameSimplifierTests.cs @@ -17,32 +17,42 @@ public class TypeNameSimplifierTests private static TypeDefinition GetType(string name) => FixtureAssembly.MainModule.Types.First(t => t.Name == name); - /// Validates that CLR primitive type names are simplified to their C# keyword aliases. - [Fact] - public void TypeNameSimplifier_Primitives_RenderLanguageAliases() + /// Validates that all 16 CLR primitive type names are simplified to their C# keyword aliases. + [Theory] + [InlineData("System.Boolean", "bool")] + [InlineData("System.Byte", "byte")] + [InlineData("System.SByte", "sbyte")] + [InlineData("System.Int16", "short")] + [InlineData("System.UInt16", "ushort")] + [InlineData("System.Int32", "int")] + [InlineData("System.UInt32", "uint")] + [InlineData("System.Int64", "long")] + [InlineData("System.UInt64", "ulong")] + [InlineData("System.Single", "float")] + [InlineData("System.Double", "double")] + [InlineData("System.Decimal", "decimal")] + [InlineData("System.Char", "char")] + [InlineData("System.String", "string")] + [InlineData("System.Object", "object")] + [InlineData("System.Void", "void")] + public void TypeNameSimplifier_Simplify_Primitives_RenderLanguageAliases(string clrName, string expected) { - // Arrange: load string, int, bool types from the fixture assembly - var sampleClass = GetType("SampleClass"); - var greetingMethod = sampleClass.Methods.First(m => m.Name == "GetGreeting"); - var stringParam = greetingMethod.Parameters[0].ParameterType; // System.String - - var arrayClass = GetType("ArrayAndNullableClass"); - var getCountMethod = arrayClass.Methods.First(m => m.Name == "GetCount"); - // int? is Nullable; verify the inner int - var countReturn = getCountMethod.ReturnType; // Nullable + // Arrange: construct a TypeReference from the CLR full name using the fixture module + var parts = clrName.Split('.'); + var ns = string.Join('.', parts[..^1]); + var name = parts[^1]; + var typeRef = new TypeReference(ns, name, FixtureAssembly.MainModule, null); // Act - var stringResult = TypeNameSimplifier.Simplify(stringParam, "ApiMark.DotNet.Fixtures"); - var countResult = TypeNameSimplifier.Simplify(countReturn, "ApiMark.DotNet.Fixtures"); + var result = TypeNameSimplifier.Simplify(typeRef, "ApiMark.DotNet.Fixtures"); - // Assert: CLR primitive names are replaced by C# aliases - Assert.Equal("string", stringResult); - Assert.Equal("int?", countResult); // Nullable → int? + // Assert: CLR primitive name is replaced by the C# keyword alias + Assert.Equal(expected, result); } /// Validates that array types are rendered using C# bracket notation rather than CLR array syntax. [Fact] - public void TypeNameSimplifier_ArrayType_ReturnsBracketNotation() + public void TypeNameSimplifier_Simplify_ArrayType_ReturnsBracketNotation() { // Arrange: GetNames() returns string[] var arrayClass = GetType("ArrayAndNullableClass"); @@ -58,7 +68,7 @@ public void TypeNameSimplifier_ArrayType_ReturnsBracketNotation() /// Validates that Nullable<T> value types are rendered as T?. [Fact] - public void TypeNameSimplifier_NullableValueTypes_UseQuestionMarkForm() + public void TypeNameSimplifier_Simplify_NullableValueTypes_UseQuestionMarkForm() { // Arrange: GetCount() returns int? (Nullable) var arrayClass = GetType("ArrayAndNullableClass"); @@ -74,7 +84,7 @@ public void TypeNameSimplifier_NullableValueTypes_UseQuestionMarkForm() /// Validates that types from well-known namespaces are rendered without their namespace prefix. [Fact] - public void TypeNameSimplifier_WellKnownNamespaceTypes_RenderWithoutNamespace() + public void TypeNameSimplifier_Simplify_WellKnownNamespaceTypes_RenderWithoutNamespace() { // Arrange: GetList() returns List (System.Collections.Generic.List) var arrayClass = GetType("ArrayAndNullableClass"); @@ -90,7 +100,7 @@ public void TypeNameSimplifier_WellKnownNamespaceTypes_RenderWithoutNamespace() /// Validates that types in the same namespace as the context drop their shared namespace prefix. [Fact] - public void TypeNameSimplifier_ContextNamespaceTypes_RenderWithoutSharedPrefix() + public void TypeNameSimplifier_Simplify_ContextNamespaceTypes_RenderWithoutSharedPrefix() { // Arrange: get a type from ApiMark.DotNet.Fixtures namespace and use same namespace as context var sampleClass = GetType("SampleClass"); @@ -106,7 +116,7 @@ public void TypeNameSimplifier_ContextNamespaceTypes_RenderWithoutSharedPrefix() /// Validates that generic type arguments are recursively simplified along with their container type. [Fact] - public void TypeNameSimplifier_GenericArguments_AreSimplifiedRecursively() + public void TypeNameSimplifier_Simplify_GenericArguments_AreSimplifiedRecursively() { // Arrange: GetAsync() returns Task var arrayClass = GetType("ArrayAndNullableClass"); @@ -156,4 +166,50 @@ public void TypeNameSimplifier_Simplify_NullableAnnotatedReferenceType_AppendsQu // Assert: the ? suffix is appended because string is a reference type Assert.Equal("string?", result); } + + /// Validates that passing null as the context namespace does not throw. + [Fact] + public void TypeNameSimplifier_Simplify_NullContextNamespace_DoesNotThrow() + { + // Arrange: any type reference + var typeRef = FixtureAssembly.MainModule.TypeSystem.String; + + // Act: passing null as contextNamespace must not throw — safe fallback expected + var result = TypeNameSimplifier.Simplify(typeRef, null!); + + // Assert: a non-null result is returned + Assert.NotNull(result); + } + + /// Validates that a multi-dimensional array type is rendered with the correct rank suffix. + [Fact] + public void TypeNameSimplifier_Simplify_MultiDimensionalArray_AppendsRankSuffix() + { + // Arrange: construct a rank-2 int array (int[,]) using Mono.Cecil + var rank2Array = new ArrayType(FixtureAssembly.MainModule.TypeSystem.Int32, 2); + + // Act + var result = TypeNameSimplifier.Simplify(rank2Array, ""); + + // Assert: rank-2 int array must simplify to "int[,]" + Assert.Equal("int[,]", result); + } + + /// + /// Validates that a type in a namespace nested under the context namespace has the + /// shared prefix stripped, leaving only the unshared suffix prepended to the type name. + /// + [Fact] + public void TypeNameSimplifier_Simplify_ContextNamespaceTypes_NestedNamespace_StripsSharedPrefix() + { + // Arrange: context = "A.B", type namespace = "A.B.C", type name = "FooType" + // Expected: the shared "A.B." prefix is stripped, leaving "C.FooType" + var typeRef = new TypeReference("A.B.C", "FooType", FixtureAssembly.MainModule, null); + + // Act + var result = TypeNameSimplifier.Simplify(typeRef, "A.B"); + + // Assert: shared "A.B." prefix is stripped; only "C.FooType" remains + Assert.Equal("C.FooType", result); + } } diff --git a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs index 38b8a93..493b2ff 100644 --- a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs +++ b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs @@ -1575,5 +1575,34 @@ public void XmlDocReader_GetExampleParts_NoCodeElement_IndentedContent_StripsCom File.Delete(path); } } -} + /// + /// Validates that renders a generic type cref + /// (e.g. T:System.Collections.Generic.List`1) using angle-bracket type-parameter + /// placeholders rather than stripping the arity entirely. + /// + [Fact] + public void XmlDocReader_GetSummary_WithSeeGenericTypeCref_FormatsWithTypeParameters() + { + // Arrange: summary contains a see cref referencing a generic type with arity backtick + var path = WriteXmlDoc(""" + + Returns a . + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("M:Foo.Bar.UseList"); + + // Assert: arity marker is rendered as angle-bracket notation, not stripped + Assert.NotNull(summary); + Assert.Contains("List", summary, StringComparison.Ordinal); + } + finally + { + File.Delete(path); + } + } +} From 0f93950b4786c57f550e82f78c9c9ce70f57b937 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 08:56:19 -0400 Subject: [PATCH 04/62] fix: Batch 3 Cpp reviews R2/R3 fixes Design docs: restore clang interface, expand MSBuild 4->9 props, add GD/SF ->CppAstModel diagram edges, add RunProcess with concurrent-drain detail, expand Parse Key Method, add platform path-comparison note, add CppGenerator External Interfaces section, fix CppNamespaceDecl in deps, cross-ref CppExternalTypeInfo, expand GD Emit method spec. Requirements: rewrite impl-detail reqs, rewrite ParseHeaders (no HOW), add RejectNullContext + WorkingDirectory reqs, remove cross-unit test refs, fix SupportAbsolute to mention WorkingDirectory. Source code: position-aware splice in CppTypeLinkResolver (String.Replace bug), knownTypes null guard, PathConventionRows 2 missing rows, TypeAliases.Count in declaration count, SanitizeFileName null guard, CppGenerator Parse null-context guard + XML doc fixes. Tests: template-arg prefix corruption regression, namespace operators page, Parse null-context, ClangAstParser null options + empty errors, IsDeleted assertion, CppEmitter final+base + GetIncludePath + WriteExternalTypesSection, tightened weak Contains assertion. Verification docs: clang-ast-parser 9 scenarios, cpp-ast-model 15 scenarios, cpp-emitter 7 scenarios, GD 5 new scenarios, SF writer-key scenario, cpp-generator ownership/links/external/deleted, TLR 4+ scenarios, api-mark-cpp single-file criterion and scenario. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 226 +++---- docs/design/api-mark-cpp/clang-ast-parser.md | 214 ++++--- docs/design/api-mark-cpp/cpp-ast-model.md | 58 +- .../cpp-emitter-gradual-disclosure.md | 135 ++-- docs/design/api-mark-cpp/cpp-emitter.md | 193 +++--- docs/design/api-mark-cpp/cpp-generator.md | 389 +++--------- .../api-mark-cpp/cpp-type-link-resolver.md | 97 ++- docs/reqstream/api-mark-cpp.yaml | 1 + .../api-mark-cpp/clang-ast-parser.yaml | 22 +- .../reqstream/api-mark-cpp/cpp-ast-model.yaml | 3 + .../cpp-emitter-gradual-disclosure.yaml | 76 ++- .../api-mark-cpp/cpp-emitter-single-file.yaml | 59 +- docs/reqstream/api-mark-cpp/cpp-emitter.yaml | 78 ++- .../reqstream/api-mark-cpp/cpp-generator.yaml | 92 ++- .../api-mark-cpp/cpp-type-link-resolver.yaml | 63 +- docs/verification/api-mark-cpp.md | 7 + .../api-mark-cpp/clang-ast-parser.md | 86 +-- .../api-mark-cpp/cpp-ast-model.md | 112 +--- .../cpp-emitter-gradual-disclosure.md | 75 ++- .../api-mark-cpp/cpp-emitter-single-file.md | 60 +- docs/verification/api-mark-cpp/cpp-emitter.md | 101 +-- .../api-mark-cpp/cpp-generator.md | 584 ++---------------- .../api-mark-cpp/cpp-type-link-resolver.md | 27 + src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 7 + src/ApiMark.Cpp/CppAst/CppAstModel.cs | 8 + src/ApiMark.Cpp/CppEmitter.cs | 1 + .../CppEmitterGradualDisclosure.cs | 6 +- src/ApiMark.Cpp/CppGenerator.cs | 5 +- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 61 +- test/ApiMark.Cpp.Tests/ClangAstParserTests.cs | 67 ++ test/ApiMark.Cpp.Tests/CppAstModelTests.cs | 13 + .../CppEmitterGradualDisclosureTests.cs | 234 ++++++- .../CppEmitterSingleFileTests.cs | 113 +++- test/ApiMark.Cpp.Tests/CppEmitterTests.cs | 166 ++++- test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs | 11 +- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 21 +- .../CppTypeLinkResolverTests.cs | 128 ++++ 37 files changed, 1935 insertions(+), 1664 deletions(-) diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index 5731d02..1d37bff 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -5,95 +5,85 @@ ## Architecture -ApiMarkCpp provides C++ language support. It reads a set of public C++ header -files by invoking clang with `clang -ast-dump=json`, parsing the resulting JSON -AST, applying a file-provenance filter to identify declarations that belong to the -documented public API, and producing the Markdown output defined by the Core -interfaces. The system contains the following units: - -- **CppGenerator** — accepts `CppGeneratorOptions` specifying public include - roots and parse environment, invokes `ClangAstParser` to obtain a fully resolved - C++ AST as structured records, filters declarations to those physically defined in - the public header files, and writes the complete gradual-disclosure Markdown tree - through IMarkdownWriterFactory. -- **CppAstModel** — group of immutable record types (`CppNamespaceDecl`, - `CppClass`, `CppFunction`, `CppField`, `CppEnum`, `CppTypeAlias`, etc.) - that represent the parsed C++ AST; constructed exclusively by `ClangAstParser`. -- **ClangAstParser** — internal parser that invokes `clang -ast-dump=json`, - deserializes the resulting JSON, and returns a `CppCompilationResult`. -- **CppEmitter** — `IApiEmitter` implementation that dispatches to the appropriate - format-specific emitter and provides shared helper methods used by both emitters. -- **CppEmitterGradualDisclosure** — writes one file per namespace, type, and - member, creating the navigable gradual-disclosure Markdown tree. -- **CppEmitterSingleFile** — writes all documentation into a single `api.md` - file using heading levels offset by `EmitConfig.HeadingDepth`. -- **CppTypeLinkResolver** — resolves C++ type strings to Markdown link text for - table cells in the generated output; tracks non-std external type references. +ApiMarkCpp provides C++ language support by parsing selected public headers with +clang, converting the JSON AST into immutable C# records, and emitting Markdown +through the ApiMarkCore interfaces. The system contains the following units: + +- **CppGenerator** — selects public headers, invokes clang, filters deprecated + top-level declarations, builds the known-type map, and returns `CppEmitter`. +- **CppAstModel** — immutable AST record types exchanged between parser and + emitters. +- **ClangAstParser** — invokes clang, walks the JSON AST, and returns + `CppCompilationResult`. +- **CppEmitter** — format dispatcher and shared-helper hub. +- **CppEmitterGradualDisclosure** — writes namespace, type, member, operator, + enum, and alias pages. +- **CppEmitterSingleFile** — writes all documentation into one `api.md` file. +- **CppTypeLinkResolver** — resolves intra-library type links and tracks external + types. ```mermaid flowchart TD - CppGenerator --> ClangAstParser["ClangAstParser (internal)"] - CppGenerator --> CppEmitter + CppGenerator --> ClangAstParser ClangAstParser --> clang["clang -ast-dump=json (OTS)"] - ClangAstParser --> CppAstModel["CppAstModel (records)"] + ClangAstParser --> CppAstModel + CppGenerator --> CppEmitter CppEmitter --> CppEmitterGradualDisclosure CppEmitter --> CppEmitterSingleFile CppEmitter --> CppTypeLinkResolver CppEmitter --> IMarkdownWriterFactory + CppEmitterGradualDisclosure --> CppAstModel + CppEmitterSingleFile --> CppAstModel ``` -CppGenerator depends on ClangAstParser and the ApiMarkCore interfaces. -ClangAstParser invokes clang as an external process and parses its JSON output. - ## External Interfaces -**IApiGenerator / IApiEmitter (provided)**: CppGenerator implements IApiGenerator from -ApiMarkCore; parsing is separated from emit via the two-stage pipeline. - -- *Type*: In-process .NET public API. -- *Role*: Provider — ApiMarkTool constructs CppGenerator and calls the two-stage - pipeline through the IApiGenerator / IApiEmitter interfaces. -- *Contract*: `CppGenerator(CppGeneratorOptions options)` constructs a - configured generator; `IApiGenerator.Parse(IContext context)` invokes clang, - filters declarations, and returns a `CppEmitter` (implements `IApiEmitter`); - `IApiEmitter.Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context)` - writes the full Markdown tree using the supplied factory and the format selected - by `config`. -- *Constraints*: CppGeneratorOptions must be fully populated before calling - Parse; all paths in PublicIncludeRoots must exist on disk. - -**clang (consumed)**: CppGenerator uses clang via `ClangAstParser` to parse C++ headers. - -- *Type*: External process (system-installed or PATH-located executable). -- *Role*: Consumer — `ClangAstParser` invokes `clang -ast-dump=json` with the - configured include paths, system include paths, preprocessor defines, and - compiler flags, then parses the JSON output into structured C++ AST records. -- *Contract*: `clang -Xclang -ast-dump=json -fparse-all-comments -fsyntax-only - -x c++ -std={standard} -I {roots} -isystem {sysroots} -D {defines} {headers}`. - The clang executable is located using this priority order: - (1) `CppGeneratorOptions.ClangPath` when set; - (2) the `APIMARK_CLANG_PATH` environment variable when set; - (3) `clang` on PATH; (4) `xcrun clang` on macOS; (5) vswhere / default LLVM path on Windows. -- *Constraints*: clang must be installed and accessible; a clear - `InvalidOperationException` is thrown when clang cannot be found. - -**MSBuild (consumed via ApiMarkTask)**: The `.targets` file sets the following -MSBuild properties used to configure generation for C++ projects: - -- `$(ApiMarkLibraryName)` — library name used as the top-level heading in - `api.md`; defaults to `$(MSBuildProjectName)` via the `.targets` file. -- `$(ApiMarkLibraryDescription)` — optional description emitted as an - introductory paragraph in `api.md`; omitted when empty or not set. -- `$(ApiMarkDefines)` — semicolon-separated list of preprocessor symbol - definitions passed to the Clang parser; semicolons are converted to commas - when forwarding to the `--defines` argument. -- `$(ApiMarkCppStandard)` — C++ language standard passed to Clang (e.g. - `c++17`, `c++20`); defaults to `c++17` via the `.targets` file. +### IApiGenerator / IApiEmitter (provided) + +- *Type*: in-process .NET interfaces. +- *Role*: provider. +- *Contract*: `CppGenerator.Parse(IContext)` returns a `CppEmitter`, and + `IApiEmitter.Emit(IMarkdownWriterFactory, EmitConfig, IContext)` writes the + requested Markdown layout. +- *Constraints*: callers must provide a valid `CppGeneratorOptions`; `Emit` throws + `ArgumentNullException` when the writer factory is null. + +### clang (consumed) + +- *Type*: out-of-process OTS CLI tool. +- *Role*: consumer. +- *Contract*: `ClangAstParser` invokes `clang -ast-dump=json -fparse-all-comments + -fsyntax-only -x c++` plus configured include paths, defines, and additional + compiler arguments. +- *Constraints*: clang must be discoverable through `ClangPath`, + `APIMARK_CLANG_PATH`, PATH, `xcrun clang`, or Windows LLVM discovery; parse + failures surface as `InvalidOperationException`. + +### MSBuild (consumed via ApiMarkTask) + +- *Type*: build-property interface exposed by the ApiMark MSBuild integration. +- *Role*: consumer. +- *Contract*: the `.targets` file forwards nine C++ properties into + `CppGeneratorOptions`: + - `$(ApiMarkLibraryName)` → `LibraryName` + - `$(ApiMarkLibraryDescription)` → `Description` + - `$(ApiMarkIncludePaths)` → `PublicIncludeRoots` (selects clang `-I` paths) + - `$(ApiMarkApiHeaders)` → `ApiHeaderPatterns` (gitignore-style header-selection + patterns) + - `$(ApiMarkDefines)` → `Defines` + - `$(ApiMarkCppStandard)` → `CppStandard` + - `$(ApiMarkClangPath)` → `ClangPath` (explicit clang executable override) + - `$(ApiMarkVisibility)` → `Visibility` (access-specifier filter) + - `$(ApiMarkIncludeObsolete)` → `IncludeDeprecated` (deprecated-API inclusion flag) +- *Constraints*: missing or invalid option values surface through constructor + validation (`ArgumentException`) or parse-time root validation + (`DirectoryNotFoundException`). ## Dependencies -- **clang**: used to parse C++ header files via `clang -ast-dump=json` — requires - clang to be installed on the host machine. No NuGet package dependency. +- **clang** — external AST parser used through `ClangAstParser`. +- **ApiMarkCore** — provides `IApiGenerator`, `IApiEmitter`, `EmitConfig`, + `IContext`, `IMarkdownWriterFactory`, and `GlobFileCollector`. ## Risk Control Measures @@ -101,60 +91,40 @@ N/A - not a safety-classified software item. ## Data Flow -1. The caller (ApiMarkTool) constructs `CppGeneratorOptions` with - PublicIncludeRoots, ApiHeaderPatterns, SystemIncludePaths, - Defines, CppStandard, AdditionalCompilerArguments, - Visibility, IncludeDeprecated, and LibraryName, then calls - `CppGenerator.Parse(context)` to obtain a `CppEmitter`. The caller then - passes an IMarkdownWriterFactory and an EmitConfig to - `CppEmitter.Emit(factory, config, context)`. -2. CppGenerator enumerates all header files under each PublicIncludeRoot. - When ApiHeaderPatterns is non-empty, patterns are applied with gitignore-style - last-match-wins semantics to produce the candidate file set; when empty, all - recognized header files under every root are included automatically. All - candidate headers are concatenated into one temporary combined header and parsed - as a single translation unit. -3. CppGenerator calls `ClangAstParser.Parse` with all candidate headers and the - configured options. `ClangAstParser` invokes `clang -ast-dump=json` and parses - the resulting JSON into `CppCompilationResult` containing `CppNamespaceDecl` - records. The parser skips AST nodes whose source file is not under a public - include root, avoiding processing of system and third-party headers. -4. `ClangAstParser` returns a `CppCompilationResult` containing all declarations - physically located in the public headers, already filtered by ownership. -5. `ClangAstParser` rejects declarations whose source file is not in the - pre-selected header set (built by `CollectHeaderFiles()` via `GlobFileCollector`). - Only declarations from selected headers are documented; system and third-party - declarations are used for type resolution only. -6. For each owned declaration, CppGenerator derives the canonical #include path - as the source file path relative to its matching PublicIncludeRoot, expressed - with forward slashes. -7. CppGenerator calls `factory.CreateMarkdown("", "api")` and writes the - library-level entrypoint listing all namespaces with the count of documented - types in each. -8. For each namespace containing owned declarations, CppGenerator calls - `factory.CreateMarkdown(qualifiedNamespace, qualifiedNamespace)` and writes - a namespace summary listing types and free functions grouped by header. -9. For each owned type, CppGenerator writes the type page with the #include - path, then emits a dedicated detail page for every visible member. Each - visible member receives its own page, except where case-insensitive filename - collisions on a single type require combining the colliding members onto one - shared page. +1. The caller builds `CppGeneratorOptions` and calls `CppGenerator.Parse(context)`. + `CppGenerator` resolves `ApiHeaderPatterns` using `WorkingDirectory` or the + process CWD, enumerates the selected headers, and invokes `ClangAstParser`. +2. `ClangAstParser` invokes clang on a temporary combined header, walks the JSON + AST, and returns `CppCompilationResult` containing only declarations whose + source files are both selected headers and under configured public include + roots. +3. `CppGenerator` logs system-header diagnostics, rejects public-header parse + failures, applies `IncludeDeprecated` while collecting namespaces, and builds a + known-type map covering namespaces, nested classes, and type aliases. +4. `CppEmitter.Emit` routes by `OutputFormat`: `SingleFile` uses + `CppEmitterSingleFile`; `GradualDisclosure` uses + `CppEmitterGradualDisclosure`. +5. During gradual-disclosure emission, regular members receive per-member pages; + case-insensitive collisions are combined onto a single lowercase page; + operator overloads are detected by name and grouped onto shared class-level or + namespace-level `operators.md` pages. +6. Type pages render the class signature line with `final` and direct base-class + names, then write member tables, nested-class tables, and alias tables. +7. Table-cell type strings are resolved through `CppTypeLinkResolver`. Exact + qualified matches and unambiguous short-name matches become Markdown links; + unknown non-`std` namespaced types are accumulated and emitted in an + `External Types` section at the bottom of the affected page. ## Design Constraints -- Platform: targets net8.0, net9.0, net10.0 as a class library. No NuGet runtime - packages are required; clang is a system dependency that must be installed - separately on the host machine. -- Parse environment: the host machine must have clang installed (LLVM, VS-bundled, - or Xcode on macOS). System include paths can be passed via SystemIncludePaths so - that system headers resolve without requiring a full toolchain on PATH. -- No compilation required: ApiMarkCpp reads source headers without building the - C++ project; no object files, link steps, or CMake configuration step is needed. -- Strict ownership model: only declarations whose source file is physically - located under a configured PublicIncludeRoot are documented. Declarations - re-exported from system or third-party headers are intentionally excluded - from the documented surface; the clang JSON walker skips non-owned subtrees. -- v1 scope: primary class and function templates are documented; partial - specializations, explicit instantiations, and C++ Concepts are out of scope. - Preprocessor macros are excluded from the documented surface. Doc comments - are parsed from the clang JSON AST via `-fparse-all-comments`. +- Platform: class library targeting modern .NET runtimes; clang is an external + host dependency. +- Header ownership: only declarations physically defined in selected public + headers are documented. +- Validation: constructor misuse raises `ArgumentNullException` or + `ArgumentException`; missing default include roots raise + `DirectoryNotFoundException`; clang discovery or AST failures raise + `InvalidOperationException`. +- Output scope: operator overloads are grouped, macros are out of scope, and + primary template declarations are documented while partial specializations and + concepts remain outside v1 scope. diff --git a/docs/design/api-mark-cpp/clang-ast-parser.md b/docs/design/api-mark-cpp/clang-ast-parser.md index 57a933d..caa030e 100644 --- a/docs/design/api-mark-cpp/clang-ast-parser.md +++ b/docs/design/api-mark-cpp/clang-ast-parser.md @@ -1,124 +1,136 @@ ## ClangAstParser - ### Purpose -ClangAstParser is an internal utility class used exclusively by CppGenerator. -It provides a single static entry point, `Parse`, that: - -1. Writes a temporary combined header file that `#include`s every supplied - header, so that all headers are processed as a single translation unit. -2. Invokes `clang -Xclang -ast-dump=json -fparse-all-comments -fsyntax-only` - on the combined header, capturing JSON on stdout and diagnostics on stderr - concurrently to avoid deadlock. -3. Deserializes the `TranslationUnitDecl` JSON tree, walking its inner nodes - and accumulating only declarations whose source file is one of the selected - (explicitly passed) header files. -4. Returns a `CppCompilationResult` containing the grouped namespaces and any - stderr error lines for the caller to log. - -ClangAstParser has a private constructor; callers must use the static `Parse` -method. The class holds no mutable state between parse calls. +ClangAstParser is the internal C++ parsing unit used by `CppGenerator`. It builds +a temporary combined header, invokes `clang -ast-dump=json`, walks the returned +`TranslationUnitDecl`, and converts owned declarations into `CppAstModel` +records. ### Data Model -**CppCompilationResult**: Immutable record returned by `Parse`. +**CppCompilationResult**: immutable result returned by `Parse`. -- `Namespaces`: `IReadOnlyList` — parsed namespaces, each - containing the owned declarations grouped by namespace. -- `Errors`: `IReadOnlyList` — error and warning lines emitted by clang - to stderr. A non-empty list does not necessarily mean the output is unusable; - clang may emit warnings for system or third-party headers while successfully - parsing owned headers. +- `Namespaces`: `IReadOnlyList` — parsed namespaces that contain + owned declarations. +- `Errors`: `IReadOnlyList` — stderr lines classified as `: error:` or + `: fatal error:` by `CollectStderrErrors`. -**NamespaceBuilder** (private inner class): Mutable accumulator used during AST -walking. One builder per unique fully-qualified namespace name. Converted to an -immutable `CppNamespaceDecl` via `Build()` once the full AST walk is complete. +**NamespaceBuilder** (private inner class): mutable namespace accumulator used +while walking the JSON AST. ### Key Methods -**ClangAstParser.Parse** (static): Invokes clang, parses the JSON AST, and -returns structured results. - -- *Parameters*: `IReadOnlyList headers` — absolute paths of the header - files to parse and use as the owned-symbol filter. Must not be null or empty. - `CppGeneratorOptions options` — generator options controlling include paths, - defines, C++ standard, and clang discovery. Must not be null. -- *Returns*: `CppCompilationResult` containing parsed namespaces and any stderr - error lines. -- *Preconditions*: `headers` must not be null or empty; `options` must not be - null; the clang executable must be locatable (see clang discovery order in - CppGeneratorOptions.ClangPath). -- *Postconditions*: The temporary combined header file is deleted from the - system temp directory. The returned `CppCompilationResult.Namespaces` contains - only declarations whose source file is both listed in the selected headers set - and under one of the `PublicIncludeRoots` entries. -- *Exceptions*: `InvalidOperationException` — thrown when the clang executable - cannot be located, when clang exits non-zero and produces no usable JSON, or - when the JSON output cannot be parsed. - -**FindClangExecutable** (private static): Resolves the clang executable path -using the following discovery order: - -1. `CppGeneratorOptions.ClangPath` — used directly when non-empty; must exist. -2. `APIMARK_CLANG_PATH` environment variable. -3. `clang` on the system PATH. -4. `xcrun clang` (macOS only). -5. vswhere LLVM discovery → `C:\Program Files\LLVM\bin\clang.exe` (Windows). - -Returns `(fileName, prefix)` where `prefix` is an empty list for direct clang -invocations and `["clang"]` for `xcrun`-wrapped invocations. - -**BuildArguments** (private static): Assembles the full argument list passed to -the clang process from structured options: AST-dump/syntax flags -(`-Xclang -ast-dump=json -fparse-all-comments -fsyntax-only -x c++`) are emitted -first, then `-std`, `-I` include roots, `-isystem` system paths, `-D` defines, -extra args, then header file paths. - -**CollectStderrErrors** (private static): filters stderr lines containing -`": error:"` or `": fatal error:"` and returns them as an immutable -list for the caller to evaluate. +#### Process orchestration + +- **Parse** (`IReadOnlyList headers`, `CppGeneratorOptions options`) → + `CppCompilationResult` — validates the header list, resolves clang, builds the + temporary combined header, runs the process, parses the JSON document, and returns + `CppCompilationResult`. + + - *Preconditions*: `headers` must not be null or empty; each entry must be + non-whitespace. `options` must not be null. + - *Postconditions*: returned `Namespaces` contains only declarations from the + supplied header files; `Errors` contains only stderr `error` or `fatal error` + lines. +- **FindClangExecutable** — resolves clang from explicit path, environment, + PATH, `xcrun clang`, or Windows LLVM discovery. +- **RunProcess** — launches the clang subprocess and concurrently drains + stdout (JSON AST) and stderr on background tasks to prevent pipe-buffer + deadlock; throws `InvalidOperationException` when the process cannot be + started or exits non-zero with no usable JSON present. +- **BuildArguments** — assembles the ordered clang argument list. +- **CollectStderrErrors** — filters stderr to `error` and `fatal error` lines + only. + +#### AST walking + +- **WalkTranslationUnit** — starts traversal at the root `inner` array. +- **WalkNodes** — updates current-file tracking and dispatches each child node by + `kind`. +- **WalkNamespace** — extends the namespace qualification and recurses. +- **WalkClassTemplate** — collects template parameters before delegating the + primary `CXXRecordDecl`. + +#### Declaration parsers + +- **ParseClass / BuildClass** — build `CppClass` records, including methods, + fields, nested classes, aliases, deprecation, `final`, and direct base types. + Base-type extraction uses two paths: clang 18+ top-level `bases` array first, + and older-clang `CXXBaseSpecifier` children through `HandleCxxBaseSpecifier` + when the top-level array is absent. +- **HandleNestedCxxRecord** — parses public nested class definitions. +- **HandleClassTemplate** — parses public nested class templates. +- **HandleTypeAliasInClass** — parses public class-scoped aliases. +- **ParseFreeFunction** — unwraps `FunctionTemplateDecl` when needed and builds + namespace-level `CppFunction` records. +- **ParseEnum** — builds `CppEnum` and enumerator records, including per-value + comments. +- **ParseTypeAlias** — builds namespace-level `CppTypeAlias` records. +- **ParseMethod** — builds `CppFunction` records for constructors and methods, + including `IsDeleted` and default parameters. +- **ParseField** — builds `CppField` records for instance and static members. +- **ParseParameter / ExtractDefaultValue** — extract parameter shapes and simple + default-argument display strings. +- **ParseAccessSpec** — converts clang access specifiers to `CppAccessibility`. + +#### Comment and text handling + +- **ParseFullComment** — converts `FullComment` nodes into `CppDocComment`. +- **HandleBlockCommandComment** — processes `@brief`, `@details`, `@remarks`, + `@return`, `@returns`, and `@note`. +- **HandleParamCommandComment** — extracts `@param` entries. +- **CollectText / CollectTextNodes** — recursively flatten text-comment content. +- **CollectVerbatimBlockText** — captures `@code` blocks as multi-line examples. +- **NormalizeSingleLine** — collapses multi-line text to a single paragraph line. + +#### Type and location helpers + +- **ExtractReturnType** — scans the function `qualType` string from right to left + to find the outermost parameter-list opening parenthesis, allowing template + return types such as `std::pair (int)` to be reduced to + `std::pair`. +- **UpdateCurrentFile / GetCurrentSourceLocation** — preserve source-file and line + context across clang nodes that omit `loc.file`. +- **IsOwned** — enforces the selected-header plus include-root ownership rule. + Uses `FileSystemPathComparer` / `FileSystemPathComparison` (selecting + `OrdinalIgnoreCase` on Windows/macOS and `Ordinal` on Linux) so that header + path matching respects the native file-system case-sensitivity of the build + host. +- **GetKind / GetName / GetQualType / GetNsBuilder / BuildNamespaces** — JSON and + namespace-builder utilities. -### External Interfaces +### Error Handling -**clang (consumed)**: ClangAstParser spawns clang as a child process and -communicates with it via its standard streams. - -- *Type*: Out-of-process OTS executable (LLVM/Clang). -- *Role*: Consumer — ClangAstParser launches clang, captures stdout (JSON AST) - and stderr (diagnostics) concurrently, and waits for exit. -- *Contract*: `clang -Xclang -ast-dump=json -fparse-all-comments -fsyntax-only - [options] ` produces a single `TranslationUnitDecl` JSON - object on stdout and diagnostic messages on stderr. Exit code 0 indicates - success; non-zero may still yield usable JSON if warnings were present. -- *Constraints*: A clang executable must be locatable at call time via the - configured discovery order. The minimum supported clang version is whatever - produces `TranslationUnitDecl` JSON via `-ast-dump=json`; in practice clang - 7+ is required. +- `ArgumentNullException` — thrown when `headers` or `options` is null. +- `ArgumentException` — thrown when `headers` is empty or contains null/whitespace + entries. +- `InvalidOperationException` — thrown when clang cannot be found, when clang exits + non-zero and produces no usable JSON, or when stdout is not valid JSON. +- Temporary combined-header cleanup occurs in a `finally` block regardless of + success or failure. -### Dependencies +### External Interfaces -N/A - ClangAstParser has no injected dependencies. All configuration is -supplied via the `options` parameter to `Parse`. The only external runtime -dependency is the clang executable resolved at call time. +#### clang (consumed) -### Error Handling +- *Type*: out-of-process OTS CLI tool. +- *Role*: consumer. +- *Contract*: launches clang as a subprocess via `RunProcess`, captures stdout + (JSON AST) and stderr, and maps a non-zero exit code with no usable JSON to + `InvalidOperationException`. +- *Constraints*: must be discoverable at runtime. + +### Dependencies -- `InvalidOperationException` — thrown by `FindClangExecutable` when no clang - executable can be located via any discovery step. -- `InvalidOperationException` — thrown when clang exits non-zero and stdout is - empty or whitespace (clang is unavailable or catastrophically broken), using - the stderr output as the diagnostic message. -- `InvalidOperationException` — thrown when the stdout cannot be parsed as a - valid JSON `TranslationUnitDecl` object (e.g. clang version is too old or - output is corrupt). -- The temporary combined header file is always deleted in a `finally` block - regardless of whether an exception is thrown. +- **CppGeneratorOptions** — supplies include roots, defines, standard, clang path, + and additional compiler arguments. +- **CppAstModel** — destination record model for parsed output. +- **System.Text.Json** — used for JSON parsing and traversal. ### Callers -- **CppGenerator** — the sole caller of `ClangAstParser.Parse`; invokes it - during `CppGenerator.Parse(context)` with the collected candidate header list - and the configured `CppGeneratorOptions`. +- **CppGenerator** — sole caller of `ClangAstParser.Parse`. diff --git a/docs/design/api-mark-cpp/cpp-ast-model.md b/docs/design/api-mark-cpp/cpp-ast-model.md index 914295b..041932f 100644 --- a/docs/design/api-mark-cpp/cpp-ast-model.md +++ b/docs/design/api-mark-cpp/cpp-ast-model.md @@ -5,61 +5,55 @@ ### Purpose -CppAstModel defines the immutable record types that represent the C++ abstract -syntax tree (AST) as parsed by `ClangAstParser`. Each record maps directly to a -construct in C++ source code and carries the documentation extracted from -Doxygen-style comments. +CppAstModel defines the immutable record types that represent the parsed C++ AST +and extracted Doxygen comments returned by `ClangAstParser` and consumed by the +Cpp emitters. ### Data Model -The following record types are defined (all in `ApiMark.Cpp`): - | Record | Description | | --- | --- | | `CppAccessibility` | Enum: `Public`, `Protected`, `Private`. | | `CppSourceLocation` | File path and line number of a declaration. | -| `CppParamDoc` | Name and description of a documented parameter. | +| `CppParamDoc` | Documented parameter name and description. | | `CppDocComment` | Parsed Doxygen comment: Summary, Details, Params, Returns, Note, Example. | -| `CppBaseType` | Name of a base class. | -| `CppTemplateParam` | Name of a template parameter. | +| `CppBaseType` | Direct base-class display name. | +| `CppTemplateParam` | Template parameter name. | | `CppEnumValue` | Enumerator name and optional doc comment. | | `CppParameter` | Function parameter: name, type name, optional default value. | -| `CppField` | Class data member: name, type, accessibility, static/deprecated flags, location, doc. | +| `CppField` | Class field: name, type, accessibility, static/deprecated flags, location, doc. | | `CppFunction` | Function or method: name, return type, parameters, accessibility, static/virtual/constructor/variadic/deprecated/deleted flags, location, doc. | -| `CppClass` | Class or struct: name, base types, template params, methods, fields, nested classes, type aliases, deprecated/final flags, location, doc. | -| `CppEnum` | Scoped or unscoped enum: name, enumerators, deprecated flag, location, doc. | -| `CppTypeAlias` | `using` type alias: name, underlying type name, deprecated flag, location, doc. | -| `CppNamespaceDecl` | One namespace: qualified name, classes, free functions, enums, type aliases, doc. | -| `CppCompilationResult` | Top-level result: list of namespace declarations and list of error strings. | +| `CppClass` | Class or struct: name, base types, template params, members, fields, nested classes, type aliases, deprecated/final flags, location, doc. | +| `CppEnum` | Scoped or unscoped enum: name, values, deprecated flag, location, doc. | +| `CppTypeAlias` | `using` alias: name, underlying type, deprecated flag, location, doc. | +| `CppNamespaceDecl` | Namespace: qualified name, classes, free functions, enums, type aliases, doc. | +| `CppCompilationResult` | Parsed namespaces plus clang error/fatal-error lines. | + +`CppFunction.IsDeleted` records whether the declaration was explicitly written with +`= delete`; emitters use the flag to append the `= delete` suffix so deleted +operations remain visible in generated documentation. ### Key Methods -N/A - CppAstModel contains only record type definitions. All construction is -performed by the C# record compiler-generated constructors. +N/A - the unit contains immutable record and enum definitions only. ### Error Handling -N/A - CppAstModel records perform no validation in their constructors; all -values are accepted as provided. Validation is the responsibility of -`ClangAstParser`, which constructs these records from verified JSON data. +N/A - validation occurs in `ClangAstParser` before record construction. ### External Interfaces -N/A - CppAstModel records are purely in-process value types. They are -constructed exclusively by `ClangAstParser` and consumed by `CppEmitter` and -`CppEmitterGradualDisclosure`. +N/A - in-process value model only. ### Dependencies -N/A - CppAstModel has no external dependencies. All types are self-contained -immutable records with no constructor logic beyond field assignment. +N/A - self-contained immutable records with no runtime dependencies beyond the BCL. ### Callers -- **ClangAstParser** — constructs all CppAstModel records during AST walking. -- **CppEmitter** — reads CppAstModel records to build shared helper outputs - (signatures, visibility filters, include-path derivation). -- **CppEmitterGradualDisclosure** — reads CppAstModel records to write all - gradual-disclosure Markdown pages. -- **CppEmitterSingleFile** — reads CppAstModel records to write the single-file - `api.md` output. +- **ClangAstParser** — constructs all model records. +- **CppGenerator** — iterates model records when building the known-type map and + grouping namespaces. +- **CppEmitter** — reads the model to build signatures, tables, and links. +- **CppEmitterGradualDisclosure** — reads the model to generate multi-file output. +- **CppEmitterSingleFile** — reads the model to generate single-file output. diff --git a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md index 2648069..c6344c8 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -5,109 +5,92 @@ ### Purpose -CppEmitterGradualDisclosure writes the multi-file gradual-disclosure Markdown -tree for C++ API documentation. It produces one file per namespace, one file per -type, one file per visible member (or combined page for case-insensitive -collisions), one file per enum, one file per type alias, and one operators page -per class and per namespace that contains operator free functions. This layout -allows AI agents and human readers to navigate progressively from the library -index to a namespace, then to a type, and finally to an individual member. +CppEmitterGradualDisclosure writes the multi-file Markdown layout for C++ API +documentation. It produces one library entry page, one namespace summary page, +one type page per class or nested class, one detail page per non-operator member, +and dedicated pages for enums, type aliases, and grouped operator overloads. ### Data Model -**PathConventionRows** (private static): Table rows listing the path convention -for each symbol kind, shown on the api index page. +**PathConventionRows** (private static): path-convention table shown on `api.md`. | Symbol kind | Path pattern | | --- | --- | | Namespace | `{Namespace}.md` | | Type | `{Namespace}/{TypeName}.md` | | Member | `{Namespace}/{TypeName}/{MemberName}.md` | +| Nested type | `{Namespace}/{OuterType}/{NestedType}.md` | | Free function | `{Namespace}/{FunctionName}.md` | | Enum | `{Namespace}/{EnumName}.md` | | Type alias | `{Namespace}/{AliasName}.md` | +| Class-scoped type alias | `{Namespace}/{TypeName}/{AliasName}.md` | | Operators (class) | `{Namespace}/{TypeName}/operators.md` | | Operators (namespace) | `{Namespace}/operators.md` | ### Key Methods -**CppEmitterGradualDisclosure.Emit** (internal): Entry point; writes the complete -gradual-disclosure tree. - -- *Parameters*: `IMarkdownWriterFactory factory`, `EmitConfig config`, - `IContext context`. -- *Returns*: `void` -- *Algorithm*: Calls `EmitGradualDisclosure(factory)` which calls `WriteApiPage`, - then iterates over namespace declarations calling `WriteNamespacePage`, - `WriteTypePage`, free-function pages, enum pages, and type-alias pages. - -**WriteApiPage** (private): Writes the library entrypoint `api.md`. - -- Emits an H1 library-name heading, optional description paragraph, an - all-namespaces table (Namespace, Declarations, Description columns), and the - path convention appendix table. - -**WriteNamespacePage** (private): Writes a namespace summary page at -`{nsKey}.md`. - -- Lists all owned classes, enums, free functions, and type aliases grouped by - source header. Operator free functions are referenced via a link to the - namespace operators page rather than listed individually. - -**WriteTypePage** (private): Writes a type page at `{nsKey}/{typeName}.md`. - -- Emits the qualified name, optional template declaration, `#include` directive, - summary, details, note, example, base types, and grouped member tables - (Constructors, Methods, Fields). Partitions methods into regular and operator; - builds a case-insensitive collision map for members; emits individual pages - for collision-free members, combined pages for collision groups, and an - operators page when operator overloads are present. - -**WriteFreeFunctionPage** (private): Writes a free-function page at -`{nsKey}/{functionName}.md`. - -**WriteEnumPage** (private): Writes an enum page at `{nsKey}/{enumName}.md`. - -- Emits the qualified name comment, `#include` directive, summary, and a table - of all enum values with their descriptions. - -**WriteClassOperatorsPage** (private): Writes the class-level operators page at -`{nsKey}/{typeName}/operators.md`. - -**WriteNamespaceOperatorsPage** (private): Writes the namespace-level operators -page at `{nsKey}/operators.md`. - -**WriteTypeAliasPage** (private): Writes a type alias page at -`{nsKey}/{aliasName}.md`. - -- Emits the qualified name comment, `#include` directive, `using` declaration, - summary, and optional details. +- **Emit / EmitGradualDisclosure**: `public void Emit(IMarkdownWriterFactory factory, + EmitConfig config, IContext context)` — writes `api.md`, then iterates namespaces, + types, nested types, free functions, enums, aliases, and operator groups. + + - *Parameters*: `IMarkdownWriterFactory factory`, `EmitConfig config`, + `IContext context`. + - *Returns*: void. + - *Preconditions*: `factory` must not be null. + - *Algorithm*: calls `WriteApiPage`, then for each namespace calls + `WriteNamespacePage`, then for each class calls `WriteTypePage` and + `WriteNestedTypePages`, then calls `WriteFreeFunctionPage`, + `WriteEnumPage`, `WriteTypeAliasPage`, `WriteClassOperatorsPage`, and + `WriteNamespaceOperatorsPage` as applicable. +- **WriteApiPage** — writes the library entrypoint, namespace table, and path + convention appendix. +- **WriteNamespacePage** — writes the namespace summary page using alphabetical + sub-tables for Types, Enums, Type Aliases, Functions, and Operators; it does not + group declarations by source header. +- **WriteTypePage** — writes a type page with signature block, summary, optional + details/note/example, base-class paragraph, member tables, nested-class table, + and type-alias table. +- **WriteNestedTypePages** — recursively writes nested class pages and class-scoped + type-alias pages beneath the parent class folder. +- **WriteClassMemberTables** — writes Constructors, Methods, Fields, Operators, + Nested Classes, and Type Aliases sections in canonical order. +- **ProcessClassConstructorMember / ProcessClassMethodMember / + ProcessClassFieldMember** — build table rows and create member pages or combined + collision pages. +- **WriteClassSignatureBlock / WriteClassBaseTypesParagraph** — render type-page + signature context and inheritance text. +- **WriteMemberPage / WriteFunctionPage / WriteFieldPage / WriteFunctionContent / + WriteFieldContent** — write per-member detail pages. +- **WriteFreeFunctionPage / WriteFreeFunctionContent** — write namespace-level free + function pages. +- **WriteEnumPage** — writes enum pages and values tables. +- **WriteTypeAliasPage** — writes namespace-level and class-scoped alias pages. +- **WriteClassOperatorsPage** — writes `{nsKey}/{TypeName}/operators.md` for all + class-scoped operators. +- **WriteNamespaceOperatorsPage** — writes `{nsKey}/operators.md` for all + namespace-level operators. ### Error Handling -N/A - CppEmitterGradualDisclosure propagates exceptions from the factory and -writer without wrapping. No additional error handling is performed. +N/A - factory and writer exceptions are propagated without additional wrapping. ### External Interfaces -**IMarkdownWriterFactory (consumed)**: Received from `CppEmitter.Emit` and used -to create each per-file Markdown writer via `CreateMarkdown`. +#### IMarkdownWriterFactory (consumed) -- *Type*: In-process .NET interface. -- *Role*: Consumer — calls `CreateMarkdown(folder, fileName)` for each output page. -- *Contract*: The factory must produce a valid `IMarkdownWriter` for every call; - callers `using`-dispose each writer after writing. +- *Type*: in-process .NET interface. +- *Role*: consumer. +- *Contract*: provides one writer per generated page. +- *Constraints*: every requested page key must be creatable. ### Dependencies -- **CppEmitter** — parent emitter providing options, visibility helpers, comment - extractors, signature builders, and `WriteCombinedMemberPage`. -- **CppTypeLinkResolver** — used to resolve type strings to Markdown links in - table cells. -- **CppAstModel** — consumes all record types produced by `ClangAstParser`. -- **IMarkdownWriterFactory** (ApiMarkCore) — supplies per-file Markdown writers. +- **CppEmitter** — provides shared helper methods and generator options. +- **CppTypeLinkResolver** — linkifies table-cell types and tracks external types. +- **CppAstModel** — supplies classes, functions, fields, enums, and aliases. +- **IMarkdownWriterFactory** — creates the individual output pages. ### Callers -- **CppEmitter.Emit** — instantiates and calls `CppEmitterGradualDisclosure.Emit` - when `config.Format` is `GradualDisclosure`. +- **CppEmitter.Emit** — instantiates this unit when + `EmitConfig.Format == OutputFormat.GradualDisclosure`. diff --git a/docs/design/api-mark-cpp/cpp-emitter.md b/docs/design/api-mark-cpp/cpp-emitter.md index 4ecf959..7a8e197 100644 --- a/docs/design/api-mark-cpp/cpp-emitter.md +++ b/docs/design/api-mark-cpp/cpp-emitter.md @@ -5,104 +5,137 @@ ### Purpose -CppEmitter implements `IApiEmitter` for C++ documentation. It holds pre-parsed -namespace declarations and dispatches Markdown generation to the appropriate -format-specific emitter: `CppEmitterGradualDisclosure` for the multi-file -gradual-disclosure layout, or `CppEmitterSingleFile` for the compact single-file -layout. CppEmitter also provides all shared helper methods used by both emitters: -visibility and deprecated filters, doc-comment extractors, signature builders, -include-path resolution, and filename sanitization. +CppEmitter implements `IApiEmitter` for C++ documentation. It stores the parsed +namespace declarations, dispatches to the format-specific emitter selected by +`EmitConfig.Format`, and centralizes the shared helper methods used by both the +single-file and gradual-disclosure layouts. ### Data Model -**CppEmitter.NamespaceDeclarations** (internal class): Mutable accumulator used -during `CppGenerator.Parse` to group owned declarations by namespace. Converted -by the caller into the immutable data passed to `CppEmitter`. +**CppEmitter.NamespaceDeclarations** (internal class): mutable namespace-grouping +accumulator populated during `CppGenerator.Parse`. -- `DisplayName`: `string` — the C++ qualified namespace name using `::` separators - (e.g. `"mylib::rendering"`). Used in heading text. -- `Doc`: `CppDocComment?` — optional namespace-level doc comment. -- `Classes`: `List` — owned classes and structs in this namespace. -- `FreeFunctions`: `List` — owned free functions (non-member) in - this namespace. -- `Enums`: `List` — owned enum declarations in this namespace. -- `TypeAliases`: `List` — owned `using` type aliases in this namespace. +- `DisplayName`: `string` — qualified namespace display name using `::`. +- `Doc`: `CppDocComment?` — optional namespace documentation. +- `Classes`: `List` — owned classes and structs. +- `Enums`: `List` — owned enums. +- `TypeAliases`: `List` — owned namespace-level aliases. +- `FreeFunctions`: `List` — owned free functions. + +**CppExternalTypeInfo** (internal record): per-page external-type entry emitted in +an `External Types` section — see _CppTypeLinkResolver Data Model_ for the full +definition. + +**CppTypePageWriteContext** (internal record): bundles per-type-page constants for +member page and table generation. + +- `Factory`: `IMarkdownWriterFactory` +- `NsKey`: `string` +- `NsDisplayName`: `string` +- `Class`: `CppClass` +- `CppResolver`: `CppTypeLinkResolver` + +**CppFunctionWriteContext** (internal record): bundles function-page constants used +by `WriteFunctionContent`. + +- `NsDisplayName`: `string` +- `ClassName`: `string` +- `CppResolver`: `CppTypeLinkResolver` +- `CurrentFolder`: `string` +- `ExternalTypes`: `ISet` +- `ParametersHeadingLevel`: `int` ### Key Methods -**CppEmitter.Emit** (implements `IApiEmitter`): Dispatches to the appropriate -format-specific emitter. - -- *Parameters*: `IMarkdownWriterFactory factory` — must not be null; throws - `ArgumentNullException` immediately when null is passed. `EmitConfig config` — - includes `Format` (GradualDisclosure or SingleFile) and `HeadingDepth`. - `IContext context` — logging channel. -- *Returns*: `void` -- *Algorithm*: when `config.Format == OutputFormat.SingleFile`, creates a new - `CppEmitterSingleFile` and calls its `Emit`; otherwise creates a new - `CppEmitterGradualDisclosure` and calls its `Emit`. - -**CppEmitter.SanitizeFileName** (internal static): Replaces characters that are -invalid in file names on Windows or Unix with underscore. - -- *Parameters*: `string name` — C++ declaration name to sanitize. Must not be null. -- *Returns*: A copy of `name` with every character from - `Path.GetInvalidFileNameChars()` replaced by `_`. -- *Algorithm*: Converts `name` to a char array, iterates, replaces invalid chars, - returns a new string. - -**CppEmitter.BuildClassDeclaration** (internal static): Builds the one-line class -declaration shown in the signature block. - -- *Parameters*: `CppClass cls` — the class to describe. -- *Returns*: A string of the form `"class ClassName"`, - `"class ClassName final"`, or `"class ClassName : public Base1, public Base2"`. -- *Algorithm*: starts with `"class {cls.Name}"`; appends `" final"` when - `cls.IsFinal`; appends `" : public {b.Name}"` for each base type. - -**CppEmitter.WriteCombinedMemberPage** (internal): Writes a single combined page for -members whose base names collide on case-insensitive filesystems. - -- *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, - `CppClass cls`, `string lowerKey` (the shared lowercase key), members list (at - least two; functions or fields), `CppTypeLinkResolver cppResolver` — type link - resolver used to linkify parameter type cells. -- *Returns*: `void` -- *Algorithm*: Creates `{nsKey}/{cls.Name}/{lowerKey}.md`; writes H1 heading using - `lowerKey`; for each function member writes an H2 heading and delegates to - `WriteFunctionContent`; for each field member writes an H2 heading and delegates - to `WriteFieldContent`. +#### Dispatch + +- **CppEmitter.Emit**: `public void Emit(IMarkdownWriterFactory factory, EmitConfig + config, IContext context)` — chooses `CppEmitterSingleFile` when + `config.Format == OutputFormat.SingleFile`; otherwise chooses + `CppEmitterGradualDisclosure`. + - _IContext description_: output channel for diagnostic messages; not used by the + emitter itself but satisfies the interface contract. + - _Preconditions_: `factory` must not be null. + - _Exceptions_: `ArgumentNullException` when `factory` is null. + +#### Visibility filtering helpers + +- **GetVisibleConstructors** — returns visible constructors after applying + `ApiVisibility` and deprecated filtering. +- **GetVisibleMethods** — returns visible non-constructor methods after applying + `ApiVisibility` and deprecated filtering. +- **GetVisibleFields** — returns visible fields after applying `ApiVisibility` and + deprecated filtering. +- **IsVisibleMember** — evaluates a single `CppAccessibility` against + `CppGeneratorOptions.Visibility`. + +#### Comment extraction helpers + +- **GetSummary** — returns the `@brief` or first-paragraph summary. +- **GetDetails** — returns `@details` / `@remarks` text. +- **GetNote** — returns `@note` text. +- **GetExample** — returns `@code` / `@endcode` example text. +- **GetParamDescription** — resolves a parameter description by name. +- **GetReturnDescription** — returns `@return` / `@returns` text. +- **GetNamespaceDescription** — returns the namespace summary or the standard + no-description placeholder. + +#### Signature builders + +- **BuildMethodSignature** — builds method, constructor, and variadic signatures, + including default values and `= delete` when `CppFunction.IsDeleted` is true. +- **BuildClassDeclaration** — renders `class Name`, optional `final`, and direct + inheritance (`: public Base1, public Base2`). +- **BuildTemplateParamDisplay** — renders `` for headings and qualified names. +- **BuildTemplateDeclaration** — renders `template` for signature + blocks. +- **SimplifyTypeName** — replaces verbose clang STL spellings with user-facing + equivalents such as `std::string`. + +#### File and page helpers + +- **SanitizeFileName** — replaces any invalid filesystem character reported by + `Path.GetInvalidFileNameChars()` with `_`. +- **GetIncludePath** — derives the canonical `#include` path relative to the + longest matching public include root. +- **GetMemberBaseName** — returns the class name for constructors and the member + name for methods or fields. +- **FileSystemPathComparison** / **FileSystemPathComparer** — static properties that + select `OrdinalIgnoreCase` (Windows/macOS) or `Ordinal` (Linux) for all path + comparisons in the emitter, ensuring include-path and page-key matching respects + native file-system case-sensitivity. +- **WriteCombinedMemberPage**: `internal static void WriteCombinedMemberPage( + IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, CppClass cls, + string lowerKey, IReadOnlyList members, CppTypeLinkResolver cppResolver)` — + writes the shared page for case-insensitive member-name collisions. +- **WriteExternalTypesSection** — writes the trailing `## External Types` table when + at least one `CppExternalTypeInfo` entry was collected on the page. ### Error Handling - `ArgumentNullException` — thrown by `Emit` when `factory` is null. -- Format-specific exceptions are propagated from `CppEmitterGradualDisclosure` - or `CppEmitterSingleFile` without wrapping. +- All writer and factory exceptions are propagated without wrapping. ### External Interfaces -**IApiEmitter (provided)**: CppEmitter implements this interface from ApiMarkCore. +#### IApiEmitter (provided) + +`CppEmitter` implements the ApiMarkCore emission contract. -- *Type*: In-process .NET public API. -- *Role*: Provider — `CppGenerator.Parse` returns a `CppEmitter` to the caller, - which then invokes `IApiEmitter.Emit`. -- *Contract*: `Emit(factory, config, context)` must write a complete Markdown - tree via the supplied factory and must not throw except for null arguments. +- _Type_: in-process .NET interface. +- _Role_: provider. +- _Contract_: `Emit` writes the full Markdown output using the supplied factory. +- _Constraints_: callers must supply a non-null factory and a fully parsed emitter. ### Dependencies -- **IMarkdownWriterFactory** (ApiMarkCore) — received through `Emit`; each - format-specific emitter calls `CreateMarkdown` to obtain per-file writers. -- **CppEmitterGradualDisclosure** — instantiated and called by `Emit` when - `config.Format` is `GradualDisclosure`. -- **CppEmitterSingleFile** — instantiated and called by `Emit` when - `config.Format` is `SingleFile`. -- **CppTypeLinkResolver** — held and forwarded to both format-specific emitters - for Markdown link generation in table cells. -- **CppAstModel** — consumes `CppNamespaceDeclarations`, `CppClass`, - `CppFunction`, `CppField`, `CppEnum`, `CppTypeAlias` record types. +- **IMarkdownWriterFactory** — creates per-page writers. +- **CppEmitterGradualDisclosure** — multi-file output implementation. +- **CppEmitterSingleFile** — single-file output implementation. +- **CppTypeLinkResolver** — linkifies table-cell types and tracks external types. +- **CppAstModel** — consumes `CppClass`, `CppFunction`, + `CppField`, `CppEnum`, and `CppTypeAlias` records. ### Callers -- **CppGenerator** — constructs a `CppEmitter` in `Parse` and returns it to the - caller as `IApiEmitter`. +- **CppGenerator** — constructs and returns `CppEmitter` from `Parse`. diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index 49375c0..f319bad 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -5,312 +5,113 @@ ### Purpose -CppGenerator implements IApiGenerator for C++ libraries. It accepts a -configured set of public include roots and parse-environment options, collects -the selected header files via `GlobFileCollector`, invokes -`clang -Xclang -ast-dump=json` on a temporary combined header, and returns a -`CppEmitter` ready to write the complete Markdown output tree. The output -structure mirrors DotNetGenerator: a library-level entrypoint, per-namespace -summaries, per-type pages, and per-member detail pages for every visible member. - -The implementation is split across six files in the `ApiMark.Cpp` package: - -- **CppGenerator.cs** — thin `IApiGenerator` that collects headers, invokes - clang, and returns a `CppEmitter`. -- **CppEmitter.cs** — `IApiEmitter` dispatcher, shared helper methods (visibility - filters, comment extractors, signature builders, include-path resolution), and - the nested `NamespaceDeclarations` data class. -- **CppEmitterGradualDisclosure.cs** — all gradual-disclosure page writers - (API index, namespace, type, member, operator, enum, type-alias pages). -- **CppEmitterSingleFile.cs** — all single-file page writers. -- **CppTypeLinkResolver.cs** — resolves C++ type strings to Markdown link text - for table cells and tracks non-std external type references. -- **CppExternalTypeInfo.cs** — holds external type documentation records - (`CppExternalTypeInfo`). +CppGenerator implements `IApiGenerator` for C++ libraries. It collects the public +headers selected by `CppGeneratorOptions.ApiHeaderPatterns`, invokes +`ClangAstParser` on a temporary combined header, groups the resulting owned +namespace declarations, and returns a `CppEmitter` that can write either the +single-file or gradual-disclosure Markdown output. ### Data Model -**CppGeneratorOptions**: Configuration value object passed to the CppGenerator -constructor. All properties must be set before the constructor is called. - -**CppGeneratorOptions.Description**: `string` — optional brief description of the -library, emitted as an introductory paragraph in `api.md`. Omitted when empty. - -**CppGeneratorOptions.LibraryName**: `string` — name of the library, used as -the top-level heading in `api.md`. - -**CppGeneratorOptions.PublicIncludeRoots**: `IReadOnlyList` — one or -more directories that define the compiler include roots. This property serves -two purposes: - -1. *Parse environment*: each root is passed to the Clang parser as an `-I` - include directory so that `#include ` resolves correctly - when parsing headers under that root. -2. *#include path derivation*: a declaration's canonical `#include` directive - is computed by taking its source file path relative to the matching root and - normalizing separators to forward slashes. For example, if the root is - `C:\project\include` and the declaration source is - `C:\project\include\mylib\renderer.h`, the canonical include path is - `mylib/renderer.h`, yielding `#include "mylib/renderer.h"` in the type - page output. - -When multiple roots are configured and a file matches more than one, the -longest matching root path (most specific) wins. - -**CppGeneratorOptions.ApiHeaderPatterns**: `IList` — ordered list of -glob and exclusion pattern strings that determine which header files contribute to the -documented public API. Gitignore-style semantics apply: patterns are evaluated -in order; the last matching pattern wins. Entries without a `!` prefix are -include patterns; entries with a `!` prefix are exclusion patterns (the `!` -is stripped before glob matching). Relative patterns are resolved against the -current working directory (CWD), matching the behavior of all other CLI glob tools -and allowing users to write patterns reflecting their project layout directly -(e.g. `include/**` when invoked from the project root). When empty, all headers -with recognized C++ extensions (`.h`, `.hpp`, `.hxx`, `.h++`) under all configured -roots are documented automatically without any pattern filtering. - -Example — all headers except `detail/`, with one re-included: -`["include/**", "!include/detail/**", "include/detail/public_api.h"]` - -**CppGeneratorOptions.SystemIncludePaths**: `IReadOnlyList` — toolchain -and SDK include directories passed to the Clang parser as system include paths -(equivalent to `-isystem` or `-I`) so that system and runtime headers -(``, ``, etc.) resolve during parsing. These directories -are used for type resolution only; declarations found within them are never -documented. - -**CppGeneratorOptions.Defines**: `IReadOnlyList` — preprocessor symbol -definitions passed to the Clang parser as `-D` flags, in the form `"NAME"` or -`"NAME=value"`. Export macros (e.g. `MYLIB_API`, `__declspec(dllexport)` -wrappers) must be defined as empty strings (e.g. `"MYLIB_API="`) so the parser -sees them as no-ops and does not misinterpret them as type annotations. - -**CppGeneratorOptions.CppStandard**: `string` — C++ language standard passed -to Clang (e.g. `"c++17"`, `"c++20"`). Defaults to `"c++17"` when not -specified. - -**CppGeneratorOptions.ClangPath**: `string?` — optional explicit path to the clang executable. -When non-empty, this path is used directly (and must exist on disk); no discovery is performed. -When null or empty, the discovery order is: `APIMARK_CLANG_PATH` environment variable → -`clang` on PATH → `xcrun clang` on macOS → vswhere LLVM / `C:\Program Files\LLVM\bin\clang.exe` on Windows. - -**CppGeneratorOptions.AdditionalCompilerArguments**: `IReadOnlyList` — -raw Clang compiler arguments appended after all structured options. Provides an -escape-hatch for toolchain-specific flags, forced includes, platform macros, or -other Clang options not covered by the structured fields (e.g. -`"--target=x86_64-pc-windows-msvc"`, `"-fms-extensions"`). Note: this option is -currently not wired through the CLI; it is available for direct API use only -(v1 scope). - -**CppGeneratorOptions.Visibility**: `ApiVisibility` — controls which class -members are included in the output. Values: `Public` (public members only), -`PublicAndProtected` (public and protected members), `All` (all members -regardless of access specifier). Applies to class and struct members; free -functions in namespaces are always included when owned. - -**CppGeneratorOptions.IncludeDeprecated**: `bool` — when false, declarations -marked with `[[deprecated]]` or compiler-specific deprecated attributes are -excluded from the output. +**CppGeneratorOptions**: Configuration value object passed to the constructor. + +- `Description`: `string` — optional library description emitted on `api.md`. +- `LibraryName`: `string` — library name used in the top-level heading. +- `PublicIncludeRoots`: `IReadOnlyList` — include roots used both for clang + `-I` arguments and canonical `#include` path derivation. The longest matching + root wins when roots overlap. +- `ApiHeaderPatterns`: `IList` — ordered gitignore-style include/exclude + patterns used to select the documented public headers. Relative patterns are + resolved from `WorkingDirectory` or the process CWD when `WorkingDirectory` is + null. +- `WorkingDirectory`: `string?` — optional working-directory anchor for resolving + relative `ApiHeaderPatterns`; default `null`, which means the current process + working directory is used. +- `SystemIncludePaths`: `IReadOnlyList` — system include directories passed + to clang for type resolution only. +- `Defines`: `IReadOnlyList` — preprocessor definitions forwarded as `-D` + arguments. +- `CppStandard`: `string` — language standard passed to clang; defaults to + `c++17`. +- `ClangPath`: `string?` — optional explicit clang path. When null or empty, + discovery falls back to `APIMARK_CLANG_PATH`, PATH, `xcrun clang`, and Windows + LLVM discovery. +- `AdditionalCompilerArguments`: `IReadOnlyList` — raw trailing clang + arguments used as an escape hatch for toolchain-specific switches. +- `Visibility`: `ApiVisibility` — class-member visibility filter consumed later by + `CppEmitter` during emission. +- `IncludeDeprecated`: `bool` — parse-time declaration filter controlling whether + deprecated classes, free functions, enums, and type aliases are retained. ### Key Methods -**CppGenerator constructor**: Accepts and stores a CppGeneratorOptions instance -for use during Generate. - -- *Parameters*: `CppGeneratorOptions options` — fully populated options object. -- *Preconditions*: `options` must not be null; `LibraryName` must be - non-empty; `PublicIncludeRoots` must contain at least one entry. -- *Postconditions*: The generator instance is ready to call Parse. - -**CppGenerator.Parse**: Collects candidate header files, invokes clang to obtain -a fully resolved C++ AST, applies visibility and deprecation filters, and returns -a `CppEmitter` holding all parsed data. - -- *Parameters*: `IContext context` — output channel for diagnostic and progress - messages emitted during parsing. Must not be null. -- *Returns*: `IApiEmitter` — a `CppEmitter` instance holding the parsed - namespace, type, and member data ready for emission. -- *Preconditions*: Each path in `PublicIncludeRoots` must exist on disk when - `ApiHeaderPatterns` is empty (`DirectoryNotFoundException` is thrown if any - path is missing); system headers must be resolvable via `SystemIncludePaths`; - `context` must not be null. Public headers are required to be self-contained — - each header must parse successfully on its own under the configured options. -- *Postconditions*: The returned `CppEmitter` contains all namespaces, types, - and members parsed from the selected headers, filtered by `Visibility` and - `IncludeDeprecated`. The caller must subsequently invoke `IApiEmitter.Emit` to - write Markdown output. - -Execution steps: call `CollectHeaderFiles()` which uses `GlobFileCollector.Collect()` -to build the selected-header set from `ApiHeaderPatterns` and `PublicIncludeRoots`; -when `ApiHeaderPatterns` is empty all headers under all roots are used directly; -when `ApiHeaderPatterns` is non-empty patterns are forwarded directly to -`GlobFileCollector`, which resolves relative patterns against -`CppGeneratorOptions.WorkingDirectory` (or the process CWD when null) and resolves -absolute patterns from their own root prefix; build Clang options from all configured -paths, defines, standard, and additional arguments; write a temporary combined -header that `#include`s all selected headers; invoke -`clang -Xclang -ast-dump=json -fparse-all-comments -fsyntax-only` on it, parse the -resulting JSON AST; `ClangAstParser` rejects non-selected declarations during AST -walking using the pre-built selected-header set; apply `Visibility` and -`IncludeDeprecated` filters; delete the temporary combined header file; return a -`CppEmitter` holding all parsed data. - -**CppEmitter.Emit** (implements `IApiEmitter`): Writes the full Markdown output tree using the -format specified by `config.Format`. - -- *Parameters*: `IMarkdownWriterFactory factory` — factory used to create each Markdown output - file; must not be null. `EmitConfig config` — output configuration including format and heading - depth. `IContext context` — output channel for diagnostic messages; not used by the emitter - itself but satisfies the interface contract. -- *Returns*: `void` -- *Preconditions*: `factory` must not be null; throws `ArgumentNullException` immediately when null - is passed. -- *Postconditions (GradualDisclosure)*: Delegates all page writing to a new - `CppEmitterGradualDisclosure` instance, producing one file per namespace, type, member, and - operator group as described in `CppGenerator.Parse`. -- *Postconditions (SingleFile)*: Delegates all page writing to a new `CppEmitterSingleFile` - instance, producing a single `api.md` file with an H{depth} library title, H{depth+1} namespace - heading, H{depth+2} type/function/enum heading (with signature and member bullet list), and - H{depth+3} individual member headings. Type links are omitted to prevent anchor collisions in the - single-file layout. The convention appendix is not included in single-file output. - -**CppEmitter.WriteCombinedMemberPage** (internal static): Writes a single combined -Markdown page for a group of members whose base names collide on case-insensitive -filesystems. - -- *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, - `CppClass cls`, `string lowerKey` — the shared lowercase base name used as the page - file name and H3 heading, `IReadOnlyList members` — the ordered collision - group (at least two elements; elements are functions or fields). -- *Returns*: `void` -- *Algorithm*: Creates `{nsKey}/{cls.Name}/{lowerKey}.md` via the factory; writes an H1 - heading using `lowerKey`; for each function member writes an H2 heading of the form - `{fn.Name} (Constructor)` or `{fn.Name} (Method)` and delegates to - `WriteFunctionContent`; for each field member writes an H2 heading of the form - `{field.Name} (Field)` and delegates to `WriteFieldContent`. - -**CppEmitterGradualDisclosure.WriteClassOperatorsPage** (private): Writes the combined operator -overloads page for a class at `{nsKey}/{cls.Name}/operators.md`. - -- *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, - `CppClass cls`, `IReadOnlyList operators` — the ordered list of operator - methods (names starting with `"operator"`); must contain at least one element. -- *Returns*: `void` -- *Algorithm*: Creates `{nsKey}/{cls.Name}/operators.md` via the factory; writes an H1 - heading `"operators"`; emits the qualified class name comment and `#include` directive - from the first operator with a source location; writes an introductory paragraph naming - the class; for each operator writes an H2 heading with the operator name and parameter - types, then delegates to `WriteFunctionContent` for signature, summary, and parameters. - -**CppEmitterGradualDisclosure.WriteNamespaceOperatorsPage** (private): Writes the combined operator -overloads page for namespace-level operator free functions at `{nsKey}/operators.md`. - -- *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, - `IReadOnlyList operators` — the ordered list of namespace-level operator - free functions (names starting with `"operator"`); must contain at least one element. -- *Returns*: `void` -- *Algorithm*: Creates `{nsKey}/operators.md` via the factory; writes an H1 heading - `"operators"`; emits the qualified name comment and `#include` directive from the first - operator with a source location; writes an introductory paragraph naming the namespace; - for each operator writes an H2 heading with the operator name and parameter types, then - delegates to `WriteFreeFunctionContent` for signature, summary, and parameters. - -**CppEmitter.GetMemberBaseName** (internal static): Returns the base name used to derive the -output file name for a class member, applying the convention that constructors are -identified by the declaring class name. - -- *Parameters*: `member` — the member whose base name to compute; - `string className` — the name of the declaring class, used for constructors. -- *Returns*: `string` — the class name when `member` is a constructor function; - the member's own `Name` for non-constructor functions and fields; the class - name as a fallback for any other member type. -- *Algorithm*: Pattern-matches on the concrete type and `IsConstructor` flag: constructor - function → `className`; non-constructor function → `fn.Name`; field → `field.Name`; - any other type → `className`. - -**CppTypeLinkResolver** (internal): Resolves C++ type strings to Markdown link text -for use in table cells. - -- *Constructor*: Accepts `IReadOnlyDictionary knownTypes` — maps - fully-qualified C++ type names (e.g. `"fixtures::SampleClass"`) to documentation - page keys (e.g. `"fixtures/SampleClass"`). Built in `CppGenerator.Parse` from - the `namespaceDecls` dictionary. -- **Linkify** method: resolves a simplified C++ type string to a Markdown link or plain text. - - *Parameters*: `string cppTypeString`, `string currentFolder`, `ISet - externalTypes` accumulator. - - *Returns*: a Markdown link `[Name](relative/path.md)` when the stripped base type - is in `knownTypes`; the original string unchanged otherwise; non-std external types - with a namespace are tracked in `externalTypes`. - - *Algorithm*: strip qualifiers (`const`, `volatile`, `*`, `&`, `&&`, trailing-const, - and template arguments) to isolate the base type name; reject primitives and `std::` - types immediately; look up the base type in `knownTypes` by exact qualified match, - then by short-name fallback; compute a relative path and return the link; if not - found and the type has a non-std namespace, track as external. -- **StripQualifiers** (internal static): removes C++ cv and reference qualifiers and - template arguments from a type string, returning the bare base type name. - -**CppExternalTypeInfo** (internal record): Represents a non-standard external C++ -type reference collected during table cell generation. - -- *Properties*: `TypeString` (short type name without namespace), `Namespace` - (the C++ namespace using `::` separators). -- *Ordering*: implements `IComparable` by `TypeString` so - `SortedSet` produces alphabetically ordered tables. - -**CppEmitter.WriteExternalTypesSection** (internal static): Emits the -`## External Types` section at the bottom of a page when at least one external -type was referenced in table cells. - -- *Parameters*: `IMarkdownWriter writer`, `SortedSet - externalTypes`. -- *Algorithm*: Returns immediately when the set is empty; otherwise writes an - H2 heading `"External Types"` and a two-column table (`Type`, `Namespace`). - -**CppEmitterGradualDisclosure.WriteTypeAliasPage** (private): Writes a documentation page for a -`using` type alias declaration. - -- *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, - `CppTypeAlias alias`. -- *Returns*: `void` -- *Algorithm*: Creates `{nsKey}/{alias.Name}.md` via the factory; writes an H1 heading - using `alias.Name`; emits the fully-qualified name comment - (`// {nsDisplayName}::{alias.Name}`), the optional `#include` directive from the source - location, and the `using {alias.Name} = {alias.UnderlyingTypeName}` declaration in a - fenced `cpp` code block; emits the doc comment summary paragraph or the no-description - placeholder; emits the extended details paragraph when a `@details` or `@remarks` block - is present. - -**CppFunction.IsDeleted** (data model property): `bool` — when `true`, the function -was declared with `= delete` in the source. `ClangAstParser` reads this from the -`"explicitlyDeleted"` field of `FunctionDecl` and `CXXMethodDecl` nodes in the clang JSON -AST. `BuildMethodSignature` appends the `= delete` suffix to the rendered signature when -this flag is set, making the intentional prohibition visible in the generated documentation. +**CppGenerator constructor**: validates and stores a `CppGeneratorOptions` +instance for later parsing. + +- *Parameters*: `CppGeneratorOptions options`. +- *Preconditions*: `options` must not be null; `LibraryName` must be non-empty; + `PublicIncludeRoots` must contain at least one entry. +- *Postconditions*: the generator is ready to call `Parse`. + +**CppGenerator.Parse**: collects the selected headers, invokes clang, applies the +parse-time deprecated filter, builds the known-type map, and returns a +`CppEmitter`. + +- *Parameters*: `IContext context` — output channel for parse diagnostics. +- *Returns*: `IApiEmitter` implemented by `CppEmitter`. +- *Preconditions*: `context` must not be null; when `ApiHeaderPatterns` is empty, + each configured public include root must exist on disk. +- *Postconditions*: the returned emitter contains all owned namespaces, classes, + free functions, enums, and type aliases from the selected headers; deprecated + declarations are excluded during `CollectResultNamespace` when + `IncludeDeprecated` is false; class-member visibility filtering is deferred to + `CppEmitter` helper methods at emit time. +- *Algorithm*: `CollectHeaderFiles()` builds the selected header set; a temporary + combined header includes every selected file; `ClangAstParser.Parse` returns + `CppCompilationResult`; `CheckForErrors` separates public-header failures from + system-header diagnostics; `CollectResultNamespace` groups declarations by + namespace key; the known-type map is flattened from namespaces, nested classes, + and type aliases; a `CppTypeLinkResolver` and `CppEmitter` are returned. + +### External Interfaces + +#### IApiGenerator (provided) + +- *Type*: in-process .NET interface. +- *Role*: provider. +- *Contract*: `Parse(IContext)` returns an `IApiEmitter` (implemented by `CppEmitter`). +- *Constraints*: `options` must not be null; `LibraryName` must be non-empty; + `PublicIncludeRoots` must contain at least one entry. + +#### IContext (consumed) + +- *Type*: in-process .NET interface. +- *Role*: consumer. +- *Contract*: output channel for parse diagnostics and progress messages. +- *Constraints*: must not be null. ### Error Handling -CppGenerator throws `DirectoryNotFoundException` when a path in -PublicIncludeRoots does not exist on disk. Parse errors returned by clang -(unresolvable includes, syntax errors in public headers) are collected and -surfaced as an exception after parsing completes, listing affected files and -Clang diagnostic messages — they are not silently ignored. Missing doc comments -produce empty documentation fields rather than an error, consistent with -DotNetGenerator behavior. +- `ArgumentNullException` — thrown by the constructor when `options` is null; thrown + by `Parse` when `context` is null. +- `ArgumentException` — thrown by the constructor when `LibraryName` is empty or + `PublicIncludeRoots` is empty. +- `DirectoryNotFoundException` — thrown when a configured include root does not + exist and default header enumeration is used. +- `InvalidOperationException` — propagated when clang cannot be located, emits no + usable JSON, or public headers produce hard parse failures. ### Dependencies -- **IApiGenerator** — CppGenerator implements this interface from ApiMarkCore. -- **IApiEmitter** — CppEmitter implements this interface from ApiMarkCore. -- **IMarkdownWriterFactory** — CppEmitter receives an IMarkdownWriterFactory - through Emit and calls CreateMarkdown to obtain each IMarkdownWriter. -- **ClangAstParser** — internal parser invoked by `CppGenerator.Parse` via - `ClangAstParser.Parse(headers, options)` to obtain the parsed C++ AST as a - `CppCompilationResult`. -- **clang** — the system clang executable (resolved via `CppGeneratorOptions.ClangPath`, - `APIMARK_CLANG_PATH` environment variable, `clang` on PATH, `xcrun clang` on macOS, or - vswhere-located clang on Windows) is invoked as a subprocess to parse C++ headers and - produce a JSON AST — see Clang Integration Design. +- **IApiGenerator** — implemented from ApiMarkCore. +- **GlobFileCollector** — performs gitignore-style header selection. +- **ClangAstParser** — parses the selected headers into `CppCompilationResult`. +- **CppEmitter** — returned by `Parse` to emit Markdown output. +- **CppTypeLinkResolver** — constructed from the flattened known-type map and + passed into `CppEmitter`. +- **clang** — consumed indirectly through `ClangAstParser`. ### Callers -- **Program** — constructs CppGenerator from CLI options for the `cpp` - subcommand and calls `Parse` and then `IApiEmitter.Emit`. +- **Program** — constructs `CppGenerator` from CLI or MSBuild options and calls + `Parse` before invoking `IApiEmitter.Emit`. diff --git a/docs/design/api-mark-cpp/cpp-type-link-resolver.md b/docs/design/api-mark-cpp/cpp-type-link-resolver.md index 8528999..35ace53 100644 --- a/docs/design/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -5,85 +5,58 @@ ### Purpose -CppTypeLinkResolver resolves C++ type strings to Markdown link text suitable for -table cells in the generated API documentation. It produces one of three outcomes -for each type string: +CppTypeLinkResolver resolves simplified C++ type strings to Markdown table-cell +content. It links documented intra-library types, leaves primitives and `std::` +types unchanged, and accumulates non-`std` external type references for later +emission in page-level `External Types` tables. -1. **Intra-library type**: a relative Markdown link `[Name](relative/path.md)` when - the stripped base name matches a known documented type (exact qualified match or - unambiguous short-name fallback). -2. **Primitive or `std::` type**: emitted as plain text and not tracked as external. -3. **Non-std external type**: emitted as plain text and added to the caller-supplied - `CppExternalTypeInfo` set for later emission in the "External Types" section. +### Data Model -Links are emitted only in table cells — never inside fenced code blocks. +**`_knownTypes`** (private): `IReadOnlyDictionary` mapping fully +qualified C++ names (`::`) to documentation page keys (`/`). -### Data Model +**`Primitives`** (private static): `HashSet` containing primitive and +fundamental C++ type names that must always remain plain text. -**`_knownTypes`** (private): `IReadOnlyDictionary` — maps -fully-qualified C++ type names using `::` separators (e.g. `"fixtures::SampleClass"`) -to documentation page keys using `/` separators (e.g. `"fixtures/SampleClass"`). -Built in `CppGenerator.Parse` and supplied at construction time. +**CppExternalTypeInfo** (internal record): ordered external-type entry recorded by +`Linkify` when the base type is not known locally. -**`Primitives`** (private static): `HashSet` — set of C++ primitive type -names (`void`, `bool`, `int`, etc.) that are always plain text and never tracked as -external. +- `TypeString`: `string` — short type name rendered in the table. +- `Namespace`: `string` — external namespace using `::` separators. +- *Ordering*: comparison sorts first by `TypeString`, then by `Namespace`, so page + output is deterministic. ### Key Methods -**CppTypeLinkResolver.Linkify** (public): Resolves a type string to Markdown link -text or plain text. - -- *Parameters*: `string cppTypeString` — the simplified C++ type string to resolve. - `string currentFolder` — folder path of the output Markdown file, used to compute - relative hrefs. `ISet externalTypes` — mutable accumulator - for non-std external type references. -- *Returns*: A Markdown link string or the original `cppTypeString` unchanged. -- *Algorithm*: - 1. Return `cppTypeString` unchanged when it is null or whitespace. - 2. Strip qualifiers via `StripQualifiers` to isolate the base type name. - 3. Return unchanged when stripped is empty, a primitive, or starts with `std::`. - 4. Look up the stripped name via `FindPageKey` (exact match, then short-name - fallback). - 5. When found: compute the relative path from `currentFolder` to the page key, - replace the short name in the original string with a Markdown link. - 6. When not found and the type has a non-std namespace: add to `externalTypes` - and return the original string. - -**FindPageKey** (private): Looks up the page key for a stripped type name. - -- Tries exact qualified-name match first; falls back to short-name scan that - returns `null` when two or more known types share the same unqualified name - (ambiguous). - -**StripQualifiers** (internal static): Removes C++ cv-qualifiers (`const`, -`volatile`), reference qualifiers (`&`, `&&`), pointer qualifiers (`*`), trailing -`const`, and template arguments from a type string to isolate the base type name. - -**ExtractNamespace** (private static): Returns the namespace portion of a qualified -C++ name (everything before the last `::`), or an empty string for unqualified names. +- **Linkify** — returns the original string unchanged for null/whitespace input, + primitives, and `std::` types; resolves exact qualified matches first; falls back + to an unambiguous short-name match; when no known type matches and the stripped + name has a non-empty non-`std` namespace, records a `CppExternalTypeInfo` entry. +- **FindPageKey** — performs exact qualified lookup first, then a short-name scan + that returns null when the short name is ambiguous. +- **StripQualifiers** — repeatedly removes leading and trailing `const`, + `volatile`, reference, pointer, and template-argument syntax until the base name + stabilizes. +- **ExtractNamespace** — returns everything before the last `::`, or an empty + string for unqualified names. ### Error Handling -N/A - CppTypeLinkResolver performs no I/O and throws no exceptions under normal -operation. Null or whitespace type strings are returned unchanged without error. +N/A - the unit performs no I/O and returns inputs unchanged for unsupported or +empty values. ### External Interfaces -N/A - CppTypeLinkResolver is an in-process utility class with no external -dependencies or outbound interfaces. All input is supplied via constructor and -method parameters. +N/A - in-process utility class only. ### Dependencies -N/A - CppTypeLinkResolver depends only on the BCL (`System.IO.Path`, -`System.Collections.Generic`). It has no dependency on ApiMarkCore or other -ApiMark units. +- **System.IO.Path** — computes relative Markdown paths. +- **System.Collections.Generic** — stores known types and external-type sets. ### Callers -- **CppEmitter** — constructs `CppTypeLinkResolver` in `CppGenerator.Parse` - from the `knownTypes` dictionary built from `namespaceDecls`, and forwards it - to both format-specific emitters. -- **CppEmitterGradualDisclosure** — calls `Linkify` in all methods that write - type and return-type cells in Markdown tables. +- **CppGenerator** — constructs the resolver from the flattened known-type map. +- **CppEmitter** — stores and forwards the resolver. +- **CppEmitterGradualDisclosure** — calls `Linkify` for return-type, parameter, + field-type, and alias-type cells. diff --git a/docs/reqstream/api-mark-cpp.yaml b/docs/reqstream/api-mark-cpp.yaml index faf7eaa..e7cfb37 100644 --- a/docs/reqstream/api-mark-cpp.yaml +++ b/docs/reqstream/api-mark-cpp.yaml @@ -25,6 +25,7 @@ sections: - ApiMarkCpp-CppGenerator-ShowDirectInheritanceInTypeSignature - ApiMarkCpp-CppGenerator-EmitIntraDocLinksInTableCells - ApiMarkCpp-CppGenerator-ApplyGitignoreStylePatternSelectionToHeaders + - ApiMarkCpp-CppGenerator-SupportAbsoluteAndRelativeHeaderPatterns - ApiMarkCpp-CppGenerator-EmitExternalTypesSection - ApiMarkCpp-CppGenerator-ShowDeletedFunctionsWithDeletedNotation - ApiMarkCpp-CppGenerator-DocumentTypeAliases diff --git a/docs/reqstream/api-mark-cpp/clang-ast-parser.yaml b/docs/reqstream/api-mark-cpp/clang-ast-parser.yaml index 4efe548..e0a38a6 100644 --- a/docs/reqstream/api-mark-cpp/clang-ast-parser.yaml +++ b/docs/reqstream/api-mark-cpp/clang-ast-parser.yaml @@ -15,18 +15,26 @@ sections: JSON-deserialization logic isolated from the higher-level generation logic, making each unit independently testable. children: [ApiMarkCpp-ClangAstParser-ParseHeaders] + tests: + - ClangAstParser_Parse_FixtureHeaders_ReturnsNonEmptyNamespaces - id: ApiMarkCpp-ClangAstParser-ParseHeaders title: >- - ClangAstParser.Parse shall write a temporary combined header, invoke - clang, parse the resulting JSON AST, and return a CppCompilationResult - containing the parsed namespaces filtered to the supplied header set. + ClangAstParser.Parse shall accept a list of C++ header file paths and + return a CppCompilationResult containing only those namespaces and + declarations physically defined in the supplied headers, with any + compiler error lines collected separately. justification: | - Concatenating all headers into one temporary file allows clang to resolve - cross-header type references in a single translation unit. Filtering the - returned declarations to those in the supplied header set prevents system - and third-party declarations from polluting the result. + Filtering returned declarations to those in the supplied header set prevents + system and third-party declarations from polluting the result. Collecting + error lines separately from namespace data allows callers to distinguish + hard parse failures from warning-only compilations. tests: + - ClangAstParser_Parse_NullHeaders_ThrowsArgumentNullException + - ClangAstParser_Parse_NullOptions_ThrowsArgumentNullException + - ClangAstParser_Parse_EmptyHeaders_ThrowsArgumentException + - ClangAstParser_Parse_InvalidExplicitClangPath_ThrowsInvalidOperationException - ClangAstParser_Parse_FixtureHeaders_ReturnsNonEmptyNamespaces - ClangAstParser_Parse_FixtureHeaders_ContainsFixturesNamespace - ClangAstParser_Parse_FixtureHeaders_FixturesNamespaceContainsSampleClass - ClangAstParser_Parse_FixtureHeaders_SampleClassHasMembers + - ClangAstParser_Parse_FixtureHeaders_ErrorsCollectionIsEmpty diff --git a/docs/reqstream/api-mark-cpp/cpp-ast-model.yaml b/docs/reqstream/api-mark-cpp/cpp-ast-model.yaml index ec11ef6..cb89ebc 100644 --- a/docs/reqstream/api-mark-cpp/cpp-ast-model.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-ast-model.yaml @@ -18,6 +18,8 @@ sections: children: - ApiMarkCpp-CppAstModel-ConstructionAndValidation - ApiMarkCpp-CppAstModel-SetsCoreProperties + tests: + - CppDocComment_Construction_SetsSummaryAndDetails - id: ApiMarkCpp-CppAstModel-ConstructionAndValidation title: >- CppAstModel record types shall be constructable from their declared @@ -32,6 +34,7 @@ sections: - CppParamDoc_Construction_SetsNameAndDescription - CppDocComment_Construction_SetsSummaryAndDetails - CppDocComment_Equality_TwoIdenticalInstances_AreEqual + - CppDocComment_NoteAndExample_WhenNotProvided_AreNull - CppBaseType_Construction_SetsName - CppTemplateParam_Construction_SetsName - CppEnumValue_Construction_SetsNameAndDoc diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml index b315d77..2623822 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml @@ -17,7 +17,16 @@ sections: deterministic. children: - ApiMarkCpp-CppEmitterGradualDisclosure-EmitApiIndexPage - - ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceAndTypePages + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceSummaryPage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitTypePage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitMemberDetailPage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitFreeFunctionPage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitEnumPage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitTypeAliasPage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitClassOperatorsPage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceOperatorsPage + tests: + - CppEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitApiIndexPage title: >- CppEmitterGradualDisclosure shall produce an api index page listing @@ -31,14 +40,69 @@ sections: tests: - CppEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage - CppEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibraryNameHeading - - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceAndTypePages + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceSummaryPage title: >- CppEmitterGradualDisclosure shall produce a namespace summary page for - each namespace and a type page for each documented class. + each namespace. justification: | - Per-namespace and per-type pages form the middle layers of the - gradual-disclosure hierarchy. Without them an agent reading the api index - would have no route to individual member pages. + Namespace pages form the middle navigation layer between the api index and + detailed type or free-function pages. tests: - CppEmitterGradualDisclosure_Emit_MinimalData_CreatesNamespacePage + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitTypePage + title: CppEmitterGradualDisclosure shall produce a type page for each documented class. + justification: | + Type pages aggregate member links, nested types, aliases, and inheritance context. + tests: - CppEmitterGradualDisclosure_Emit_MinimalData_CreatesTypePage + - CppEmitterGradualDisclosure_Emit_NestedClass_CreatesNestedClassPage + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitMemberDetailPage + title: >- + CppEmitterGradualDisclosure shall produce a member detail page for each + visible non-operator member, combining only case-insensitive collisions. + justification: | + Dedicated member pages make navigation deterministic while still handling + case-collision edge cases safely. + tests: + - CppEmitterGradualDisclosure_Emit_MethodMember_CreatesMemberDetailPage + - CppEmitterGradualDisclosure_Emit_CaseInsensitiveCollision_CreatesCombinedPage + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitFreeFunctionPage + title: CppEmitterGradualDisclosure shall produce a detail page for each regular free function. + justification: | + Namespace-level free functions need stable standalone pages in the multi-file layout. + tests: + - CppEmitterGradualDisclosure_Emit_FreeFunction_CreatesFreeFunctionPage + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitEnumPage + title: CppEmitterGradualDisclosure shall produce a detail page for each documented enum. + justification: | + Enum pages preserve value tables and associated documentation without + inflating namespace pages. + tests: + - CppEmitterGradualDisclosure_Emit_Enum_CreatesEnumPage + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitTypeAliasPage + title: >- + CppEmitterGradualDisclosure shall produce detail pages for namespace-level + and class-scoped type aliases. + justification: | + Public aliases are first-class API declarations and require stable paths. + tests: + - CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitClassOperatorsPage + title: >- + CppEmitterGradualDisclosure shall group class-scoped operators onto a + shared operators page for the owning type. + justification: | + Operator names often normalize to colliding file names, so a shared page + avoids invalid or ambiguous output paths. + tests: + - CppEmitterGradualDisclosure_Emit_ClassOperators_CreatesOperatorsPage + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceOperatorsPage + title: >- + CppEmitterGradualDisclosure shall group namespace-level operators onto a + shared namespace operators page. + justification: | + Namespace operator overloads need the same collision-safe grouping as class + operators. + tests: + - CppGenerator_Generate_NamespaceFreeOperator_CreatesNamespaceOperatorsPage + - CppEmitterGradualDisclosure_Emit_NamespaceOperators_CreatesOperatorsPage diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml index 06c29b7..a9c296a 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml @@ -13,18 +13,61 @@ sections: Single-file output is required for consumers who want a compact, linear reference instead of the gradual-disclosure folder tree. Writing all content into one file simplifies indexing and search. - children: [ApiMarkCpp-CppEmitterSingleFile-EmitSingleApiFile] - - id: ApiMarkCpp-CppEmitterSingleFile-EmitSingleApiFile + children: + - ApiMarkCpp-CppEmitterSingleFile-CreatesSingleApiWriter + - ApiMarkCpp-CppEmitterSingleFile-EmitsLibraryNameHeading + - ApiMarkCpp-CppEmitterSingleFile-EmitsNamespaceHeading + - ApiMarkCpp-CppEmitterSingleFile-EmitsClassSection + - ApiMarkCpp-CppEmitterSingleFile-EmitsFreeFunctionSection + - ApiMarkCpp-CppEmitterSingleFile-EmitsEnumSection + - ApiMarkCpp-CppEmitterSingleFile-EmitsMemberSection + tests: + - CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter + - id: ApiMarkCpp-CppEmitterSingleFile-CreatesSingleApiWriter title: >- - CppEmitterSingleFile shall create exactly one writer keyed as api, - include the library name as a top-level heading, and include a namespace - heading. + CppEmitterSingleFile shall create exactly one writer keyed as api. justification: | - A single output file with a deterministic key (api) and correct heading - structure is required for tool-chain consumers that process the file - programmatically and for readers who expect a linear document layout. + A single output file with a deterministic key is required for tool-chain + consumers that process the file programmatically. tests: - CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter - CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsLibraryNameHeading + title: CppEmitterSingleFile shall emit the library name as the top-level heading. + justification: | + The single-file document must identify the documented library immediately. + tests: - CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHeading + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsNamespaceHeading + title: CppEmitterSingleFile shall emit a namespace heading for each documented namespace. + justification: | + Namespace headings preserve structural navigation in the linear document. + tests: - CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeading + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsClassSection + title: CppEmitterSingleFile shall emit a section for each documented class. + justification: | + Class sections keep type-level information discoverable in single-file output. + tests: + - CppEmitterSingleFile_Emit_ClassData_ContainsClassSection + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsFreeFunctionSection + title: CppEmitterSingleFile shall emit a section for each documented free function. + justification: | + Free functions must remain directly navigable in the single-file layout. + tests: + - CppEmitterSingleFile_Emit_FreeFunction_ContainsFreeFunctionSection + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsEnumSection + title: CppEmitterSingleFile shall emit a section for each documented enum. + justification: | + Enum pages in gradual disclosure correspond to explicit enum sections in the + single-file layout. + tests: + - CppEmitterSingleFile_Emit_Enum_ContainsEnumSection + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsMemberSection + title: >- + CppEmitterSingleFile shall emit individual member headings below the owning + class section using the configured heading-depth offset. + justification: | + Member sections preserve drill-down detail while keeping the document linear. + tests: + - CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml index 4f1a0dc..7e8f0a3 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml @@ -15,25 +15,85 @@ sections: format-specific logic allows each format emitter to be tested in isolation while keeping shared utilities (visibility filters, signature builders, include-path resolution) in one place. - children: [ApiMarkCpp-CppEmitter-WriteCombinedMemberPage] + children: + - ApiMarkCpp-CppEmitter-WriteCombinedMemberPageForCaseCollisions + - ApiMarkCpp-CppEmitter-RejectNullFactory + - ApiMarkCpp-CppEmitter-SanitizeFileName + - ApiMarkCpp-CppEmitter-BuildClassDeclaration + - ApiMarkCpp-CppEmitter-RepresentExternalTypeInfo + - ApiMarkCpp-CppEmitter-RepresentTypePageWriteContext + - ApiMarkCpp-CppEmitter-RepresentFunctionWriteContext tests: - CppEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles - CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile - - id: ApiMarkCpp-CppEmitter-WriteCombinedMemberPage + - id: ApiMarkCpp-CppEmitter-WriteCombinedMemberPageForCaseCollisions title: >- CppEmitter shall emit a combined member page for case-insensitive - filename collisions within a single type, reject null factories, and - sanitize file names for operator declarations. + filename collisions within a single type. justification: | On case-insensitive filesystems members whose names differ only in case collide at the file-system level. CppEmitter must detect these collisions - and merge the affected members onto one shared page. Null-factory rejection - provides fast-fail behavior. File-name sanitization prevents invalid - characters in operator names from causing I/O errors. + and merge the affected members onto one shared page. + tests: + - CppEmitter_WriteCombinedMemberPage_CaseInsensitiveCollision_ProducesSingleCombinedPage + - id: ApiMarkCpp-CppEmitter-RejectNullFactory + title: CppEmitter shall reject a null markdown-writer factory. + justification: | + Emission cannot proceed without a writer factory, so null input must fail + immediately with a clear argument error. tests: - CppEmitter_Emit_NullFactory_ThrowsArgumentNullException - - CppEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles - - CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile + - id: ApiMarkCpp-CppEmitter-SanitizeFileName + title: >- + CppEmitter shall replace any invalid filesystem character in an output + file name with an underscore. + justification: | + File-name sanitization prevents invalid characters in operator names and + other declarations from causing I/O errors during emission. + tests: - CppEmitter_SanitizeFileName_RegularName_IsUnchanged + - CppEmitter_SanitizeFileName_InvalidCharacters_AreReplacedWithUnderscore + - id: ApiMarkCpp-CppEmitter-BuildClassDeclaration + title: >- + CppEmitter shall render class declaration lines with final and direct + public base-type information when present. + justification: | + The class declaration line gives readers immediate inheritance and finality + context without opening the source header. + tests: - CppEmitter_BuildClassDeclaration_NonFinalNoBase_ReturnsJustClassName - CppEmitter_BuildClassDeclaration_FinalClass_AppendsFinalKeyword + - CppEmitter_BuildClassDeclaration_WithBaseTypes_AppendsInheritanceList + - id: ApiMarkCpp-CppEmitter-RepresentExternalTypeInfo + title: >- + CppEmitter shall emit an External Types section listing referenced + external types in alphabetical order on pages that reference at least + one non-std external type. + justification: | + Readers using an external library type referenced in a table cell need to + know which namespace to include. Collecting all external type references per + page and listing them alphabetically provides this context without cluttering + individual type cells with fully-qualified names. CppTypeLinkResolver + accumulates the entries; CppEmitter writes the section using + WriteExternalTypesSection. + tests: + - CppEmitter_WriteExternalTypesSection_WithEntries_WritesExternalTypesSection + - id: ApiMarkCpp-CppEmitter-RepresentTypePageWriteContext + title: >- + CppEmitter shall produce consistent type page output for a given class, + regardless of how many times Emit is called with the same inputs. + justification: | + Consistent output guarantees that the emitter is purely functional with + respect to its inputs, so that documentation generation is reproducible and + deterministic. + tests: + - CppEmitterGradualDisclosure_Emit_MethodMember_CreatesMemberDetailPage + - id: ApiMarkCpp-CppEmitter-RepresentFunctionWriteContext + title: >- + CppEmitter shall produce consistent function page output for a given + function, regardless of how many times Emit is called with the same inputs. + justification: | + Consistent output guarantees that function page generation is reproducible + and deterministic across both gradual-disclosure and single-file formats. + tests: + - CppEmitterGradualDisclosure_Emit_ClassOperators_CreatesOperatorsPage diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index 1163cca..b2a8dbc 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -18,7 +18,6 @@ sections: tests: - CppGenerator_Constructor_NullOptions_ThrowsArgumentNullException - CppGenerator_Generate_NonexistentIncludeRoot_ThrowsDirectoryNotFoundException - - CppEmitter_Emit_NullFactory_ThrowsArgumentNullException - id: ApiMarkCpp-CppGenerator-ParseHeadersViaClang title: CppGenerator shall parse public header files via clang -ast-dump=json to obtain a fully resolved C++ AST. @@ -29,10 +28,6 @@ sections: without requiring a bundled parser library. tests: - CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint - - ClangAstParser_Parse_FixtureHeaders_ReturnsNonEmptyNamespaces - - ClangAstParser_Parse_FixtureHeaders_ContainsFixturesNamespace - - ClangAstParser_Parse_FixtureHeaders_FixturesNamespaceContainsSampleClass - - ClangAstParser_Parse_FixtureHeaders_SampleClassHasMembers - id: ApiMarkCpp-CppGenerator-FilterDeclarationsByOwnership title: >- CppGenerator shall apply a file-provenance filter that documents only @@ -54,14 +49,12 @@ sections: - id: ApiMarkCpp-CppGenerator-EmitDetailPageForEveryMember title: >- CppGenerator shall emit a dedicated detail page for every visible non-operator - member - of every documented class and free function, making navigation fully - deterministic. + class member and every regular free-function category entry, except where + case-insensitive collisions require a shared member page. justification: | - All non-operator members always receive their own dedicated file so that navigation - is fully deterministic. An AI or tool can always resolve - {Namespace}/{TypeName}/{MemberName}.md without inspecting member content or shape. - Exception: members whose base names collide on a case-insensitive filesystem are + Dedicated detail pages keep navigation deterministic for visible non-operator + members and for regular free functions. Members whose base names collide on a + case-insensitive filesystem are combined onto a single shared page (see ApiMarkCpp-CppGenerator-EmitCombinedMemberPageForCaseInsensitiveCollisions). Operator overloads are grouped onto a single operators.md page (see @@ -89,7 +82,7 @@ sections: justification: | Different consumers need different views of the same C++ class, ranging from a public API reference to broader internal documentation for development teams. The - visibility filter must be applied consistently before any member page is emitted. + visibility filter is applied by the emitter when it selects which members to render. tests: - CppGenerator_Generate_PublicVisibility_ExcludesProtectedMethod - CppGenerator_Generate_PublicAndProtectedVisibility_IncludesProtectedMethod @@ -117,11 +110,38 @@ sections: description paragraph; @param and @return are rendered in their respective sections; @code/@endcode blocks are rendered as fenced code blocks; missing doc comments emit a standard placeholder. + children: + - ApiMarkCpp-CppGenerator-RenderBriefDocComment + - ApiMarkCpp-CppGenerator-RenderParamAndReturnDocComments + - ApiMarkCpp-CppGenerator-RenderCodeExampleDocComments + - ApiMarkCpp-CppGenerator-RenderMissingCommentPlaceholder + - id: ApiMarkCpp-CppGenerator-RenderBriefDocComment + title: CppGenerator shall render @brief text as the declaration summary paragraph. + justification: | + The summary paragraph is the primary description shown on type and member pages. tests: - CppGenerator_Generate_TypeWithDocComment_WritesSummaryToParagraph - CppGenerator_Generate_MethodWithDocComment_WritesSummaryToParagraph - - CppGenerator_Generate_MissingDocComment_WritesPlaceholder + - id: ApiMarkCpp-CppGenerator-RenderParamAndReturnDocComments + title: CppGenerator shall render @param and @return text in their corresponding sections. + justification: | + Parameter and return documentation are necessary for correct API consumption. + tests: + - CppGenerator_Generate_MethodWithParameters_CreatesMemberPage + - CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell + - id: ApiMarkCpp-CppGenerator-RenderCodeExampleDocComments + title: CppGenerator shall render @code/@endcode blocks as fenced code examples. + justification: | + Example blocks provide usage guidance without requiring readers to open source files. + tests: - CppGenerator_Generate_MethodWithCodeExample_EmitsCodeBlockOnMemberPage + - CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock + - id: ApiMarkCpp-CppGenerator-RenderMissingCommentPlaceholder + title: CppGenerator shall emit a standard placeholder when a declaration has no doc comment. + justification: | + A consistent placeholder makes undocumented symbols obvious while keeping the page shape stable. + tests: + - CppGenerator_Generate_MissingDocComment_WritesPlaceholder - id: ApiMarkCpp-CppGenerator-ProduceConsistentOutputFileStructure title: CppGenerator shall produce output files following a consistent naming convention. @@ -183,6 +203,7 @@ sections: - CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature - CppEmitter_BuildClassDeclaration_NonFinalNoBase_ReturnsJustClassName - CppEmitter_BuildClassDeclaration_FinalClass_AppendsFinalKeyword + - CppEmitter_BuildClassDeclaration_WithBaseTypes_AppendsInheritanceList - id: ApiMarkCpp-CppGenerator-EmitIntraDocLinksInTableCells title: CppGenerator shall emit Markdown links in type and parameter type table cells when the referenced type is documented in the same library. @@ -216,21 +237,32 @@ sections: - id: ApiMarkCpp-CppGenerator-SupportAbsoluteAndRelativeHeaderPatterns title: >- CppGenerator shall support both absolute and relative ApiHeaderPatterns - glob - patterns: relative patterns shall be resolved from the current working - directory (CWD), - and absolute patterns shall be forwarded directly to GlobFileCollector. + glob patterns: relative patterns shall be resolved from + CppGeneratorOptions.WorkingDirectory (defaulting to the process CWD when + WorkingDirectory is null or empty), and absolute patterns shall be + forwarded directly to GlobFileCollector. justification: | - CWD-relative patterns match how all other CLI glob tools work and allow - users to write patterns that reflect their project directory layout directly - (e.g. `include/**` when invoked from the project root) without needing to - hard-code absolute paths. Absolute patterns allow headers on other drives or - outside all configured roots to be included directly, supporting build layouts - where public headers are not all under a common tree. + WorkingDirectory-relative patterns allow users to write patterns that reflect + their project directory layout directly (e.g. `include/**` when WorkingDirectory + is the project root) without needing to hard-code absolute paths. Absolute + patterns allow headers on other drives or outside all configured roots to be + included directly. WorkingDirectory defaults to the process CWD so existing + callers that omit the property continue to work correctly. tests: - GlobFileCollector_Collect_AbsolutePattern_FindsFiles - CppGenerator_Generate_ApiHeaderPatterns_CwdRelativePattern_OnlyMatchingFilesDocumented - CppGenerator_Generate_ApiHeaderPatterns_CwdRelativeExclusionPattern_ExcludesMatchingFiles + - id: ApiMarkCpp-CppGenerator-WorkingDirectory + title: >- + CppGenerator shall use CppGeneratorOptions.WorkingDirectory as the base + path when resolving relative ApiHeaderPatterns, falling back to the + process working directory when WorkingDirectory is null or empty. + justification: | + Build systems often invoke tools from a directory other than the project root. + A configurable WorkingDirectory anchor lets callers pin the resolution base + independently of the process CWD, making builds reproducible across machines. + tests: + - CppGenerator_Generate_ApiHeaderPatterns_CwdRelativePattern_OnlyMatchingFilesDocumented - id: ApiMarkCpp-CppGenerator-EmitExternalTypesSection title: CppGenerator shall emit an "External Types" section at the bottom of each generated page that references at least one non-std external @@ -241,7 +273,7 @@ sections: references per page and listing them alphabetically provides this context without cluttering individual type cells with fully-qualified names. tests: - - CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType + - CppTypeLinkResolver_Linkify_ExternalType_AddsToExternalTypesSet - id: ApiMarkCpp-CppGenerator-ShowDeletedFunctionsWithDeletedNotation title: >- CppGenerator shall document explicitly deleted functions and operators @@ -273,6 +305,8 @@ sections: - CppGenerator_Generate_TypeAlias_CreatesAliasPages - CppGenerator_Generate_TypeAliasPage_ContainsDeclarationAndSummary - CppGenerator_Generate_NamespacePage_ListsTypeAliases + - CppGenerator_Generate_ClassScopedTypeAlias_CreatesAliasPage + - CppGenerator_Generate_ClassScopedTypeAlias_ListedOnClassPage - id: ApiMarkCpp-CppGenerator-EmitSingleFileOutput title: CppGenerator shall emit a single api.md file when OutputFormat.SingleFile is selected. @@ -287,3 +321,11 @@ sections: - CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly - CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHeading - CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeading + - id: ApiMarkCpp-CppGenerator-RejectNullContext + title: CppGenerator.Parse shall reject a null IContext argument. + justification: | + Parse cannot emit diagnostics without a valid context channel. Failing fast + with ArgumentNullException ensures callers receive a clear error rather than + a null-reference failure deep inside the generation pipeline. + tests: + - CppGenerator_Parse_NullContext_ThrowsArgumentNullException diff --git a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml index 10bdf4b..81a397c 100644 --- a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml @@ -15,19 +15,62 @@ sections: human readers to navigate directly to the referenced type page rather than requiring a separate search. Correct handling of ambiguous short names and unresolvable external types prevents broken links and incorrect navigation. - children: [ApiMarkCpp-CppTypeLinkResolver-Linkify] - - id: ApiMarkCpp-CppTypeLinkResolver-Linkify + children: + - ApiMarkCpp-CppTypeLinkResolver-LinkifyExactQualifiedMatch + - ApiMarkCpp-CppTypeLinkResolver-LinkifyUnambiguousShortName + - ApiMarkCpp-CppTypeLinkResolver-LinkifyAmbiguousShortName + - ApiMarkCpp-CppTypeLinkResolver-LinkifyPrimitiveType + - ApiMarkCpp-CppTypeLinkResolver-LinkifyStdType + - ApiMarkCpp-CppTypeLinkResolver-LinkifyExternalTypeTracking + tests: + - CppTypeLinkResolver_Linkify_ExactQualifiedMatch_EmitsLink + - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyExactQualifiedMatch title: >- - CppTypeLinkResolver.Linkify shall emit a Markdown link for intra-library - types (exact or unambiguous short-name match) and plain text for - primitives, std:: types, and ambiguous references. + CppTypeLinkResolver.Linkify shall emit a Markdown link when the stripped + base type matches a known fully qualified type exactly. justification: | - Exact qualified-name matching is the primary resolution path and must - always produce a link. The short-name fallback allows callers to use - unqualified names when only one type has that name. Ambiguous short names - must not produce links because the target would be non-deterministic. + Exact qualified-name matching is the primary resolution path and must remain + deterministic even when short names are ambiguous. tests: - CppTypeLinkResolver_Linkify_ExactQualifiedMatch_EmitsLink + - CppTypeLinkResolver_Linkify_QualifiedType_StripsQualifiersBeforeLookup + - CppTypeLinkResolver_Linkify_QualifiedReferenceToAmbiguousType_EmitsCorrectLink + - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyUnambiguousShortName + title: >- + CppTypeLinkResolver.Linkify shall emit a Markdown link for an unqualified + type name when exactly one known type has that short name. + justification: | + Short-name fallback improves readability when a unique target exists. + tests: - CppTypeLinkResolver_Linkify_UnambiguousShortName_EmitsLink + - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyAmbiguousShortName + title: >- + CppTypeLinkResolver.Linkify shall leave an unqualified type name unchanged + when two or more known types share that short name. + justification: | + Ambiguous links would point to non-deterministic destinations and must be avoided. + tests: - CppTypeLinkResolver_Linkify_AmbiguousShortName_EmitsPlainText - - CppTypeLinkResolver_Linkify_QualifiedReferenceToAmbiguousType_EmitsCorrectLink + - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyPrimitiveType + title: CppTypeLinkResolver.Linkify shall leave primitive types unchanged. + justification: | + Primitive types are not documentation pages and must remain plain text. + tests: + - CppTypeLinkResolver_Linkify_PrimitiveType_ReturnsUnchanged + - CppTypeLinkResolver_Linkify_NullInput_ReturnsNull + - CppTypeLinkResolver_Linkify_WhitespaceInput_ReturnsUnchanged + - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyStdType + title: CppTypeLinkResolver.Linkify shall leave std:: types unchanged. + justification: | + Standard-library types are outside the generated documentation tree. + tests: + - CppTypeLinkResolver_Linkify_StdType_ReturnsUnchanged + - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyExternalTypeTracking + title: >- + CppTypeLinkResolver.Linkify shall accumulate an external type entry when + no known type matches and the stripped type has a non-empty non-std namespace. + justification: | + External type tracking lets page writers emit a concise External Types + section without cluttering each individual table cell. + tests: + - CppTypeLinkResolver_Linkify_ExternalType_AddsToExternalTypesSet diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index 12afb48..b771099 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -36,6 +36,8 @@ configuration beyond a standard clang installation is required. - Output files follow the naming convention: `api.md` entrypoint, `{namespace}.md` namespace summaries, `{namespace}/{TypeName}.md` type pages, `{namespace}/{AliasName}.md` type alias pages, and `{namespace}/{TypeName}/{MemberName}.md` member detail pages. +- When the single-file format is specified, all documentation is written to a single `api.md` + file using a flat H1/H2/H3/H4 heading hierarchy. ## Test Scenarios @@ -185,3 +187,8 @@ summary as a description paragraph. This scenario is tested by "Type Aliases" section that lists every owned alias so readers can discover them without opening individual alias pages. This scenario is tested by `CppGenerator_Generate_NamespacePage_ListsTypeAliases`. + +**Single-file format writes all namespaces to one api.md file**: Verifies that when the +`--format single-file` option is specified, all documentation is written to a single `api.md` +file using a flat heading hierarchy rather than producing separate namespace and type pages. This +scenario is tested by `CppGenerator_Generate_SingleFileFormat_WritesToSingleFile`. diff --git a/docs/verification/api-mark-cpp/clang-ast-parser.md b/docs/verification/api-mark-cpp/clang-ast-parser.md index bd74f47..a389a30 100644 --- a/docs/verification/api-mark-cpp/clang-ast-parser.md +++ b/docs/verification/api-mark-cpp/clang-ast-parser.md @@ -3,49 +3,65 @@ ### Verification Approach `ClangAstParser` is integration-tested in `test/ApiMark.Cpp.Tests/ClangAstParserTests.cs` -using real C++ fixture headers located in `test/ApiMark.Cpp.Fixtures/include/`. The tests -invoke `ClangAstParser.Parse` directly with actual header file paths and a real -`CppGeneratorOptions` instance. All tests check for clang availability using -`IsClangAvailable()` and call `Assert.Skip` when clang is not on PATH, so the tests are -safe to run in environments without clang. No mock or stub is used for the clang executable -itself; the test verifies the real integration path. +using real fixture headers and a real clang installation. The tests exercise argument guards, +clang invocation, AST parsing, and representative namespace/type/member extraction. ### Test Environment -Tests require the fixture header files in `test/ApiMark.Cpp.Fixtures/include/` (located via -`[CallerFilePath]` resolution in `FixturePaths`) and a system clang installation accessible -on PATH. When clang is not available the tests are automatically skipped. No external network -access or privileged configuration is required. +Tests require fixture headers in `test/ApiMark.Cpp.Fixtures/include/` and a system clang +installation accessible on PATH. When clang is not available, integration tests are skipped. ### Acceptance Criteria -- Parsing the fixture headers returns a non-empty `Namespaces` collection. -- The returned namespaces include one whose `QualifiedName` contains `"fixtures"`. -- The `fixtures` namespace contains a class named `SampleClass`. -- `SampleClass` has at least one member. +- `Parse` rejects `null` headers with `ArgumentNullException`. +- `Parse` rejects a `null` options argument with `ArgumentNullException`. +- `Parse` rejects an empty header list with `ArgumentException`. +- `Parse` throws `InvalidOperationException` when an explicit clang path is invalid. +- Parsing the fixture headers returns a non-empty namespace set containing `fixtures`, + `SampleClass`, and at least one member. +- `CppCompilationResult.Errors` contains only stderr error/fatal-error lines collected from + clang (clean fixture parse produces an empty errors list). ### Test Scenarios -**Parse fixture headers returns non-empty namespaces**: Verifies that -`ClangAstParser.Parse` invoked with the fixture header files and a valid -`CppGeneratorOptions` returns a `CppCompilationResult` whose `Namespaces` -collection is non-empty, confirming that clang was invoked successfully and the -JSON AST was parsed and filtered correctly. -This scenario is tested by `ClangAstParser_Parse_FixtureHeaders_ReturnsNonEmptyNamespaces`. - -**Parse fixture headers contains the fixtures namespace**: Verifies that the -returned namespaces include one whose `QualifiedName` contains `"fixtures"`, -confirming that the fixture headers' namespace declarations are correctly -deserialized from the clang JSON AST. -This scenario is tested by `ClangAstParser_Parse_FixtureHeaders_ContainsFixturesNamespace`. - -**Fixtures namespace contains SampleClass**: Verifies that the `fixtures` namespace -returned by `Parse` contains a class named `SampleClass`, confirming that class -declarations inside the fixture namespace are correctly collected into the result. -This scenario is tested by +**Null headers rejected**: Verifies that passing a null header list to `Parse` throws +`ArgumentNullException` immediately. Tested by +`ClangAstParser_Parse_NullHeaders_ThrowsArgumentNullException`. + +**Null options rejected**: Verifies that passing a null `CppGeneratorOptions` to `Parse` +throws `ArgumentNullException` immediately. Tested by +`ClangAstParser_Parse_NullOptions_ThrowsArgumentNullException`. + +**Empty headers rejected**: Verifies that passing an empty header list to `Parse` throws +`ArgumentException`. Tested by `ClangAstParser_Parse_EmptyHeaders_ThrowsArgumentException`. + +**Invalid explicit clang path rejected**: Verifies that specifying a non-existent clang +executable path causes `Parse` to throw `InvalidOperationException`. Tested by +`ClangAstParser_Parse_InvalidExplicitClangPath_ThrowsInvalidOperationException`. + +**Fixture headers return non-empty namespace set**: Verifies that parsing the real fixture +headers produces at least one namespace in the result, confirming the full clang invocation +and AST-walking pipeline is wired correctly. Tested by +`ClangAstParser_Parse_FixtureHeaders_ReturnsNonEmptyNamespaces`. + +**Fixture headers contain the fixtures namespace**: Verifies that the parsed result includes a +namespace named `fixtures`, confirming that the ownership filter correctly selects declarations +from the supplied headers. Tested by +`ClangAstParser_Parse_FixtureHeaders_ContainsFixturesNamespace`. + +**Fixtures namespace contains SampleClass**: Verifies that `SampleClass` appears in the +`fixtures` namespace, confirming that class-level declaration parsing is correct. Tested by `ClangAstParser_Parse_FixtureHeaders_FixturesNamespaceContainsSampleClass`. -**SampleClass has members**: Verifies that the `SampleClass` class in the -`fixtures` namespace has at least one member, confirming that the member-collection -logic within the JSON walker operates correctly on the fixture header. -This scenario is tested by `ClangAstParser_Parse_FixtureHeaders_SampleClassHasMembers`. +**SampleClass has at least one member**: Verifies that the parsed `SampleClass` record +contains at least one method, confirming that member-level parsing within a class is functional. +Tested by `ClangAstParser_Parse_FixtureHeaders_SampleClassHasMembers`. + +**Clean fixture parse produces empty errors list**: Verifies that `CppCompilationResult.Errors` +is empty when parsing well-formed fixture headers, confirming that `CollectStderrErrors` +correctly filters out non-error output and returns no false positives. Tested by +`ClangAstParser_Parse_FixtureHeaders_ErrorsCollectionIsEmpty`. + +**Known integration-only gap**: Non-zero exit and malformed JSON paths are not isolated by the +current implementation without adding a process seam, so those behaviors remain covered only by +real-process integration when reproducible in environment-specific failure cases. diff --git a/docs/verification/api-mark-cpp/cpp-ast-model.md b/docs/verification/api-mark-cpp/cpp-ast-model.md index b63c22f..88f7927 100644 --- a/docs/verification/api-mark-cpp/cpp-ast-model.md +++ b/docs/verification/api-mark-cpp/cpp-ast-model.md @@ -3,98 +3,54 @@ ### Verification Approach `CppAstModel` record types are unit-tested in `test/ApiMark.Cpp.Tests/CppAstModelTests.cs` -without any test doubles. Each record type is constructed directly using its C# -record primary constructor, and the resulting property values are asserted immediately. -Tests are organized into two groups: construction-and-validation tests (one per record -type, verifying basic property assignment) and core-properties tests (for the more -complex record types `CppField`, `CppFunction`, and `CppClass`, verifying behavioral -flag properties in addition to name and type). No clang installation or file system -access is required. +without any test doubles. Each record type is constructed directly and asserted for stored +property values, optional null defaults, value equality, and core behavioral flags. ### Test Environment -No external services, network access, or file system access are required. Tests run with -the standard xUnit.net test runner. +No external services, network access, clang installation, or file system access are required. +Tests run with the standard xUnit.net test runner. ### Acceptance Criteria - Every record type stores its constructor parameters in the correct properties. -- Record types with default-value parameters (`CppParameter.DefaultValue`, - `CppDocComment.Note`, `CppDocComment.Example`) report `null` when the parameter - is omitted. -- `CppDocComment` implements value equality: two instances constructed with identical - parameters are equal. -- `CppAccessibility` contains exactly the values `Public`, `Protected`, and `Private`. -- `CppField`, `CppFunction`, and `CppClass` expose their core behavioral flags - (`IsStatic`, `IsConstructor`, `IsFinal`) correctly after construction. +- `CppParameter.DefaultValue`, `CppDocComment.Note`, and `CppDocComment.Example` are `null` + when omitted. +- `CppDocComment` implements value equality. +- `CppAccessibility` contains exactly `Public`, `Protected`, and `Private`. +- `CppField`, `CppFunction`, and `CppClass` expose their core behavioral flags correctly. ### Test Scenarios -**CppSourceLocation stores file and line**: Verifies that constructing a -`CppSourceLocation("myfile.h", 42)` produces `File == "myfile.h"` and `Line == 42`. -This scenario is tested by `CppSourceLocation_Construction_SetsFileAndLine`. +**CppDocComment note/example default to null**: Verifies that constructing `CppDocComment` +without `Note` or `Example` leaves both properties `null`, matching the documented optional +metadata contract. This scenario is tested by +`CppDocComment_NoteAndExample_WhenNotProvided_AreNull`. -**CppParamDoc stores name and description**: Verifies that `CppParamDoc("count", -"Number of items.")` stores both fields correctly. -This scenario is tested by `CppParamDoc_Construction_SetsNameAndDescription`. +**Record types store constructor parameters**: Verifies that `CppField`, `CppFunction`, and +`CppClass` each store their constructor arguments in the correct properties. Tested by +`CppField_Construction_SetsCoreProperties`, `CppFunction_Construction_SetsCoreProperties`, +and `CppClass_Construction_SetsCoreProperties`. -**CppDocComment stores Summary and Details**: Verifies that `CppDocComment` stores -the `Summary` and `Details` parameters correctly after construction. -This scenario is tested by `CppDocComment_Construction_SetsSummaryAndDetails`. +**CppParameter.DefaultValue is null when omitted**: Verifies that constructing a +`CppParameter` without a default value leaves `DefaultValue` as `null`. Tested by +`CppParameter_DefaultValue_WhenNotProvided_IsNull`. -**CppDocComment record equality**: Verifies that two `CppDocComment` instances -constructed with identical parameters are equal via C# record value equality. -This scenario is tested by `CppDocComment_Equality_TwoIdenticalInstances_AreEqual`. +**CppDocComment implements value equality**: Verifies that two `CppDocComment` instances +constructed with identical arguments compare as equal, confirming record-type value +semantics. Tested by `CppDocComment_Equality_TwoIdenticalInstances_AreEqual`. -**CppBaseType stores name**: Verifies that `CppBaseType("Shape")` stores `Name == -"Shape"`. -This scenario is tested by `CppBaseType_Construction_SetsName`. +**CppAccessibility contains Public, Protected, Private**: Verifies that the +`CppAccessibility` enum exposes exactly these three values, matching the documented +member-access contract. Tested by `CppAccessibility_Values_ArePublicProtectedPrivate`. -**CppTemplateParam stores name**: Verifies that `CppTemplateParam("T")` stores -`Name == "T"`. -This scenario is tested by `CppTemplateParam_Construction_SetsName`. +**CppFunction.IsDeleted flag is correctly stored**: Verifies that a `CppFunction` +constructed with `IsDeleted = false` reports that value correctly from the `IsDeleted` +property. Tested by `CppFunction_Construction_SetsCoreProperties`. -**CppEnumValue stores name and doc**: Verifies that `CppEnumValue` stores `Name` -and `Doc` correctly. -This scenario is tested by `CppEnumValue_Construction_SetsNameAndDoc`. +**CppClass.IsFinal flag is correctly stored**: Verifies that a `CppClass` constructed with +`IsFinal = false` reports that value correctly. Tested by +`CppClass_Construction_SetsCoreProperties`. -**CppParameter stores name and type name**: Verifies that `CppParameter("radius", -"double")` stores `Name == "radius"` and `TypeName == "double"`. -This scenario is tested by `CppParameter_Construction_SetsNameAndTypeName`. - -**CppParameter default value is null when not provided**: Verifies that -`CppParameter("radius", "double")` has `DefaultValue == null` because the optional -parameter was not supplied. -This scenario is tested by `CppParameter_DefaultValue_WhenNotProvided_IsNull`. - -**CppEnum stores name and values**: Verifies that `CppEnum` stores `Name` and a -`Values` list with the correct count. -This scenario is tested by `CppEnum_Construction_SetsNameAndValues`. - -**CppTypeAlias stores name and underlying type**: Verifies that `CppTypeAlias` -stores `Name` and `UnderlyingTypeName` correctly. -This scenario is tested by `CppTypeAlias_Construction_SetsNameAndUnderlyingType`. - -**CppNamespaceDecl stores qualified name**: Verifies that `CppNamespaceDecl` stores -`QualifiedName` correctly. -This scenario is tested by `CppNamespaceDecl_Construction_SetsQualifiedName`. - -**CppCompilationResult stores namespaces and errors**: Verifies that -`CppCompilationResult` stores `Namespaces` and `Errors` correctly. -This scenario is tested by `CppCompilationResult_Construction_SetsNamespacesAndErrors`. - -**CppAccessibility has expected values**: Verifies that `CppAccessibility` contains -`Public`, `Protected`, and `Private` enum values. -This scenario is tested by `CppAccessibility_Values_ArePublicProtectedPrivate`. - -**CppField stores core properties**: Verifies that `CppField` stores `Name`, -`TypeName`, `Accessibility`, and `IsStatic` correctly after construction. -This scenario is tested by `CppField_Construction_SetsCoreProperties`. - -**CppFunction stores core properties**: Verifies that `CppFunction` stores `Name`, -`ReturnTypeName`, `Accessibility`, and `IsConstructor` correctly after construction. -This scenario is tested by `CppFunction_Construction_SetsCoreProperties`. - -**CppClass stores core properties**: Verifies that `CppClass` stores `Name`, -`BaseTypes`, and `IsFinal` correctly after construction. -This scenario is tested by `CppClass_Construction_SetsCoreProperties`. +**CppField.IsDeprecated flag**: Verifies that `CppField` stores the `IsDeprecated` flag +supplied at construction time. Tested by `CppField_Construction_SetsCoreProperties`. diff --git a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md index 412d452..dec7136 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -3,46 +3,55 @@ ### Verification Approach `CppEmitterGradualDisclosure` is unit-tested in -`test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs` without invoking clang. -A `BuildMinimalData` helper constructs a `CppEmitter` and a controlled namespace -declaration containing one class (`Widget`), along with a `CppTypeLinkResolver`. -An `InMemoryMarkdownWriterFactory` test double captures all created writers and their -content. Tests cover the creation and content of the api index page, namespace page, -and type page using this minimal synthetic dataset. +`test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs` using synthetic namespace/type +structures and an `InMemoryMarkdownWriterFactory` to capture every emitted page. ### Test Environment -No external services, network access, clang installation, or file system access are -required. Tests run with the standard xUnit.net test runner. +No external services, network access, clang installation, or file system access are required. +Tests run with the standard xUnit.net test runner. ### Acceptance Criteria -- `CppEmitterGradualDisclosure.Emit` creates a writer keyed as `("", "api")` for - the api index page. -- The api index page heading contains the configured library name (`"TestLib"`). -- A namespace page is created for the `"testlib"` namespace key. -- A type page is created for the `Widget` class. +- The emitter creates the api index, namespace summary, and type pages. +- The emitter creates detail pages for visible members and free functions. +- The emitter creates enum pages, type-alias pages, nested-type pages, and operator pages. +- Case-insensitive collisions are combined onto one page. +- Empty namespace collections still produce an `api.md` fallback page. ### Test Scenarios -**Emit creates the api index page**: Verifies that `CppEmitterGradualDisclosure.Emit` -produces a writer keyed as `("", "api")` in the factory, confirming that the api -index page generation path is wired correctly. -This scenario is tested by `CppEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage`. - -**Emit creates a namespace page**: Verifies that the emitter produces at least one -writer whose key contains `"testlib"`, confirming that per-namespace page generation -is wired correctly. -This scenario is tested by `CppEmitterGradualDisclosure_Emit_MinimalData_CreatesNamespacePage`. - -**Emit creates a type page for Widget**: Verifies that the emitter produces at least -one writer whose key contains `"Widget"`, confirming that per-type page generation is -wired correctly. -This scenario is tested by `CppEmitterGradualDisclosure_Emit_MinimalData_CreatesTypePage`. - -**Api index page heading contains library name**: Verifies that the api index page -produced by the emitter contains a heading whose text includes `"TestLib"`, confirming -that the library name from `CppGeneratorOptions` is correctly used in the top-level -heading. -This scenario is tested by +**Class operators page**: Tested by `CppEmitterGradualDisclosure_Emit_ClassOperators_CreatesOperatorsPage`. + +**Enum page**: Tested by `CppEmitterGradualDisclosure_Emit_Enum_CreatesEnumPage`. + +**Type alias page**: Tested by `CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage`. + +**Nested class page**: Tested by `CppEmitterGradualDisclosure_Emit_NestedClass_CreatesNestedClassPage`. + +**Case-insensitive collision**: Tested by +`CppEmitterGradualDisclosure_Emit_CaseInsensitiveCollision_CreatesCombinedPage`. + +**Empty namespace fallback**: Tested by +`CppEmitterGradualDisclosure_Emit_EmptyNamespaces_ApiPageContainsFallbackParagraph`. + +**Member detail page**: Tested by `CppEmitterGradualDisclosure_Emit_MethodMember_CreatesMemberDetailPage`. + +**Free-function page**: Tested by `CppEmitterGradualDisclosure_Emit_FreeFunction_CreatesFreeFunctionPage`. + +**Api index page creation**: Verifies that the emitter creates the api index page. Tested by +`CppEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage`. + +**Namespace page creation**: Verifies that a namespace summary page is created for each +namespace. Tested by `CppEmitterGradualDisclosure_Emit_MinimalData_CreatesNamespacePage`. + +**Type page creation**: Verifies that a type page is created for each documented class. +Tested by `CppEmitterGradualDisclosure_Emit_MinimalData_CreatesTypePage`. + +**Library name heading**: Verifies that the api index page heading contains the library +name. Tested by `CppEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibraryNameHeading`. + +**Namespace operators page**: Verifies that namespace-level operator overloads are grouped +onto a shared `{namespace}/operators.md` page. Tested by +`CppEmitterGradualDisclosure_Emit_NamespaceOperators_CreatesOperatorsPage`. diff --git a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md index e2035c7..8ac7239 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md @@ -3,46 +3,38 @@ ### Verification Approach `CppEmitterSingleFile` is unit-tested in -`test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs` without invoking clang. A -`BuildMinimalData` helper constructs a `CppEmitter` and a controlled namespace declaration -containing one class (`Widget`), along with a `CppTypeLinkResolver`. An -`InMemoryMarkdownWriterFactory` test double captures the single created writer and its -content. Tests verify that exactly one writer is created, that its key is `("", "api")`, -that the api file contains the library-name heading, and that the api file contains a -namespace heading. +`test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs` using synthetic namespace data and an +`InMemoryMarkdownWriterFactory` to capture the single output document. ### Test Environment -No external services, network access, clang installation, or file system access are -required. Tests run with the standard xUnit.net test runner. +No external services, network access, clang installation, or file system access are required. +Tests run with the standard xUnit.net test runner. ### Acceptance Criteria -- `CppEmitterSingleFile.Emit` creates exactly one writer in the factory. -- The single writer is keyed as `("", "api")`. -- The api file contains a heading whose text includes the library name (`"TestLib"`). -- The api file contains a heading whose text includes the namespace name (`"testlib"`). +- `CppEmitterSingleFile.Emit` creates exactly one writer keyed as `("", "api")`. +- The api file contains the library-name heading and namespace headings. +- The api file emits class, free-function, and enum sections when present. +- Member headings respect non-default heading-depth offsets. ### Test Scenarios -**Emit creates exactly one writer**: Verifies that `CppEmitterSingleFile.Emit` -produces exactly one writer in the factory, confirming that all content is written -to a single file. -This scenario is tested by `CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter`. - -**Emit creates the api writer only**: Verifies that the single writer created by the -emitter is keyed as `("", "api")`, confirming that the output uses the expected file -key convention. -This scenario is tested by `CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly`. - -**Api file contains library name heading**: Verifies that the api file produced by -the emitter contains a heading whose text includes `"TestLib"`, confirming that the -library name from `CppGeneratorOptions` is correctly used in the top-level heading. -This scenario is tested by -`CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHeading`. - -**Api file contains namespace heading**: Verifies that the api file contains a heading -whose text includes `"testlib"`, confirming that per-namespace headings are correctly -emitted in the single-file layout. -This scenario is tested by -`CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeading`. +**Emit class section**: Verifies that class data is rendered into a dedicated class section. +This scenario is tested by `CppEmitterSingleFile_Emit_ClassData_ContainsClassSection`. + +**Emit free-function section**: Verifies that namespace free functions are rendered into the +single-file output. This scenario is tested by +`CppEmitterSingleFile_Emit_FreeFunction_ContainsFreeFunctionSection`. + +**Emit enum section**: Verifies that enum declarations are rendered into the single-file +output. This scenario is tested by `CppEmitterSingleFile_Emit_Enum_ContainsEnumSection`. + +**Heading depth offset**: Verifies that member headings shift when a non-default heading depth +is configured. This scenario is tested by +`CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings`. + +**Creates exactly one writer keyed as api**: Verifies that the single-file emitter creates +exactly one Markdown writer keyed as `api` at the output root. Tested by +`CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter` and +`CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly`. diff --git a/docs/verification/api-mark-cpp/cpp-emitter.md b/docs/verification/api-mark-cpp/cpp-emitter.md index 026aa00..d2577dc 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter.md +++ b/docs/verification/api-mark-cpp/cpp-emitter.md @@ -2,59 +2,66 @@ ### Verification Approach -`CppEmitter` is unit-tested in `test/ApiMark.Cpp.Tests/CppEmitterTests.cs` without -invoking clang. A `BuildMinimalEmitter` helper constructs a `CppEmitter` directly with -a controlled `SortedDictionary` and a -`CppTypeLinkResolver`, avoiding any I/O. An `InMemoryMarkdownWriterFactory` test double -captures emitted content. Tests cover null-argument rejection, format dispatch (gradual -vs. single-file), filename sanitization, and class-declaration building. +`CppEmitter` is unit-tested in `test/ApiMark.Cpp.Tests/CppEmitterTests.cs` without invoking +clang. Tests construct controlled namespace/type data and capture output with an +`InMemoryMarkdownWriterFactory`. ### Test Environment -No external services, network access, clang installation, or file system access are -required. Tests run with the standard xUnit.net test runner. +No external services, network access, clang installation, or file system access are required. +Tests run with the standard xUnit.net test runner. ### Acceptance Criteria -- `CppEmitter.Emit` with a null factory throws `ArgumentNullException` immediately. -- `OutputFormat.GradualDisclosure` produces more than one writer in the factory. -- `OutputFormat.SingleFile` produces exactly one writer keyed as `"api"`. -- `SanitizeFileName` replaces characters invalid in file names (e.g. `*` in - `"operator*"`) with underscores. -- `SanitizeFileName` leaves names that contain no invalid characters unchanged. -- `BuildClassDeclaration` returns `"class ClassName"` for a non-final class with no - base types. -- `BuildClassDeclaration` includes `"final"` in the result for a class marked `IsFinal`. +- `CppEmitter.Emit` rejects a null factory. +- Gradual-disclosure dispatch produces multiple files. +- Single-file dispatch produces exactly one `api` file. +- `SanitizeFileName` preserves valid names and replaces invalid characters with underscores. +- `BuildClassDeclaration` renders non-final, final, and inherited class declarations correctly. +- `WriteCombinedMemberPage` emits one shared page for case-insensitive member collisions. ### Test Scenarios -**Emit rejects null factory**: Verifies that passing `null` as the factory argument -to `CppEmitter.Emit` throws `ArgumentNullException`, providing a clear error rather -than a null-reference failure during I/O. -This scenario is tested by `CppEmitter_Emit_NullFactory_ThrowsArgumentNullException`. - -**GradualDisclosure format produces multiple files**: Verifies that calling `Emit` -with `OutputFormat.GradualDisclosure` results in more than one writer being created -by the factory, confirming dispatch to `CppEmitterGradualDisclosure`. -This scenario is tested by `CppEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles`. - -**SingleFile format produces a single api file**: Verifies that calling `Emit` with -`OutputFormat.SingleFile` results in exactly one writer keyed as `"api"`, confirming -dispatch to `CppEmitterSingleFile`. -This scenario is tested by `CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile`. - -**SanitizeFileName leaves regular names unchanged**: Verifies that -`SanitizeFileName("MyClass")` returns `"MyClass"` unchanged when the input contains -no invalid file-name characters. -This scenario is tested by `CppEmitter_SanitizeFileName_RegularName_IsUnchanged`. - -**BuildClassDeclaration for non-final class with no base**: Verifies that -`BuildClassDeclaration` returns `"class Circle"` for a class with no base types and -`IsFinal == false`. -This scenario is tested by -`CppEmitter_BuildClassDeclaration_NonFinalNoBase_ReturnsJustClassName`. - -**BuildClassDeclaration for final class appends final keyword**: Verifies that -`BuildClassDeclaration` includes the word `"final"` in the result when `IsFinal` is true. -This scenario is tested by -`CppEmitter_BuildClassDeclaration_FinalClass_AppendsFinalKeyword`. +**Sanitize invalid characters**: Verifies that invalid file-name characters are replaced with +underscores instead of causing output-path failures. This scenario is tested by +`CppEmitter_SanitizeFileName_InvalidCharacters_AreReplacedWithUnderscore`. + +**Null factory rejection**: Verifies that passing null to `CppEmitter.Emit` throws +`ArgumentNullException` before any I/O occurs. Tested by +`CppEmitter_Emit_NullFactory_ThrowsArgumentNullException`. + +**GradualDisclosure dispatch produces multiple files**: Verifies that requesting +`OutputFormat.GradualDisclosure` results in more than one writer being created. Tested by +`CppEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles`. + +**SingleFile dispatch produces exactly one api file**: Verifies that requesting +`OutputFormat.SingleFile` creates exactly one writer keyed as `api`. Tested by +`CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile`. + +**SanitizeFileName preserves valid names**: Verifies that names containing only valid +file-name characters are returned unchanged. Tested by +`CppEmitter_SanitizeFileName_RegularName_IsUnchanged`. + +**BuildClassDeclaration with base types**: Verifies that base class names are appended to +the class declaration line in the form `class X : public Base`. Tested by +`CppEmitter_BuildClassDeclaration_WithBaseTypes_AppendsInheritanceList`. + +**BuildClassDeclaration with final and base types**: Verifies that both the `final` keyword +and the inheritance list are correctly combined in the declaration string. Tested by +`CppEmitter_BuildClassDeclaration_FinalClassWithBaseTypes_AppendsFinalAndInheritance`. + +**WriteCombinedMemberPage for case-insensitive collisions**: Verifies that members whose +names differ only in case are merged onto a single lowercase-keyed page. Tested by +`CppEmitter_WriteCombinedMemberPage_CaseInsensitiveCollision_ProducesSingleCombinedPage`. + +**GetIncludePath returns relative path for matching root**: Verifies that a source file +residing under a configured public include root produces a root-relative, forward-slash +path. Tested by `CppEmitter_GetIncludePath_MatchingRoot_ReturnsRelativePath`. + +**GetIncludePath returns full path when no root matches**: Verifies that a source file not +under any configured root produces the full normalized path. Tested by +`CppEmitter_GetIncludePath_NoMatchingRoot_ReturnsFileName`. + +**WriteExternalTypesSection emits heading with entries**: Verifies that a non-empty +external-types set causes `WriteExternalTypesSection` to write an "External Types" heading. +Tested by `CppEmitter_WriteExternalTypesSection_WithEntries_WritesExternalTypesSection`. diff --git a/docs/verification/api-mark-cpp/cpp-generator.md b/docs/verification/api-mark-cpp/cpp-generator.md index b85e3eb..099e74a 100644 --- a/docs/verification/api-mark-cpp/cpp-generator.md +++ b/docs/verification/api-mark-cpp/cpp-generator.md @@ -2,560 +2,70 @@ ### Verification Approach -`CppGenerator` is integration-tested in `test/ApiMark.Cpp.Tests/` using real C++ fixture headers -located in `test/ApiMark.Cpp.Fixtures/include/`. System clang is used via `ClangAstParser` as-is -because header parsing and declaration metadata interpretation are central to the unit's -responsibility. Tests are organized by behavioral area: error handling, ownership filtering, -visibility filtering, deprecated filtering, Doxygen doc comment rendering, output file structure, -enums, templates, inheritance, free functions, constructors, qualified names, and variadic -functions. An `InMemoryMarkdownWriterFactory` test double (from `ApiMark.Core.TestHelpers`) is -supplied to capture emitted content without writing to the file system. +`CppGenerator` is integration-tested in `test/ApiMark.Cpp.Tests/` using real C++ fixture +headers from `test/ApiMark.Cpp.Fixtures/include/` and the real `ClangAstParser`. Tests cover +configuration guards, ownership filtering, visibility filtering, deprecated filtering, +Doxygen rendering, output structure, operator grouping, inheritance, aliases, external-type +tracking, and single-file output. ### Test Environment -Tests require the fixture header files in `test/ApiMark.Cpp.Fixtures/include/` (located via -`[CallerFilePath]` resolution in `FixturePaths`) and a system clang installation accessible on -PATH (or via xcrun on macOS / vswhere on Windows). No external service, network dependency, or -privileged configuration is required beyond a standard clang installation. +Tests require the fixture headers and a system clang installation accessible on PATH. +`InMemoryMarkdownWriterFactory` captures output without writing files. ### Acceptance Criteria - All `CppGenerator` tests pass with zero failures. -- The ownership filter produces type pages only for declarations physically defined under a - configured PublicIncludeRoot and whose source file was selected by the api-headers patterns - (when patterns are configured); transitively-included dependency headers that are under a - PublicIncludeRoot but not selected by --api-headers are excluded. -- Visibility filtering correctly excludes non-public members under Public mode, includes protected - members under PublicAndProtected mode, and includes private members under All mode. -- Deprecated filtering correctly excludes declarations marked `[[deprecated]]` when IncludeDeprecated - is false and includes them when it is true. -- Doxygen `@brief` comments appear as description paragraphs in generated output; missing doc - comments produce the standard placeholder text. -- Every visible non-operator member — including parameterless methods, constructors, free - functions, and variadic functions — receives its own dedicated detail page unless - case-insensitive file-name collisions require combining members onto one page. -- Operator overloads declared inside a class are all grouped onto a single - `{namespace}/{TypeName}/operators.md` page; the type page links to it via an Operators - section rather than listing individual operator pages. -- Namespace-level operator free functions are all grouped onto a single - `{namespace}/operators.md` page; the namespace summary page links to it via an Operators - section. -- Generated file keys follow the naming convention: `api` entrypoint, `{namespace}` namespace - summaries, `{namespace}/{TypeName}` type pages, `{namespace}/{TypeName}/{MemberName}` member - pages, `{namespace}/{TypeName}/operators` class operator pages, and `{namespace}/operators` - namespace operator pages. -- Type and member pages contain the fully qualified C++ name in their signature blocks. -- Enum pages list all declared enum values. -- Template class primary templates receive their own type pages. -- Null-input error paths throw the expected exception types immediately. -- When `ApiHeaderPatterns` is empty, all headers under configured PublicIncludeRoots with - recognized C++ extensions are documented. When patterns are configured, only headers whose last - matching pattern is a positive (non-`!`) pattern are included; gitignore last-match-wins - semantics apply. Relative patterns are resolved from the current working directory so that users - can write patterns such as `include/**` when invoking the tool from the project root. -- Methods returning types documented within the same library emit Markdown links in the Returns - column of the Methods table; methods returning types not found in the library emit plain text. -- Types referenced in member signatures that are not documented within the library are tracked - and listed in the External Types section of the library entrypoint. +- Ownership filtering includes only declarations under configured public roots and selected + by `ApiHeaderPatterns` when patterns are present. +- Deprecated filtering occurs during parse-time namespace collection; visibility filtering is + applied during emit-time member selection. +- Every visible non-operator member and regular free function receives a deterministic detail + page unless case-insensitive collisions require a combined page. +- Class operators and namespace operators are grouped onto shared operator pages. +- Intra-library type references emit Markdown links in table cells; unknown non-std types are + tracked for External Types sections. +- Type pages show fully qualified names, deleted notation, finality, and direct inheritance. +- Public type aliases, enums, nested classes, and single-file output are documented correctly. ### Test Scenarios -**Constructor rejects null options**: Verifies that passing a null options object to the CppGenerator -constructor throws `ArgumentNullException` immediately, confirming that misconfigured callers fail -fast before any I/O is attempted. This scenario is tested by -`CppGenerator_Constructor_NullOptions_ThrowsArgumentNullException`. +**Inheritance signature includes base class**: Verifies that a derived class page includes its +base class in the rendered class declaration line. This scenario is tested by +`CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature`. -**Generate rejects null factory**: Verifies that passing a null factory to `CppEmitter.Emit` -(obtained from `CppGenerator.Parse`) throws `ArgumentNullException`, providing a clear error rather -than an unrelated null-reference failure during I/O. This scenario is tested by -`CppGenerator_Generate_NullFactory_ThrowsArgumentNullException`. - -**Generate throws for nonexistent include root**: Verifies that Generate throws -`DirectoryNotFoundException` when a configured PublicIncludeRoot path does not exist on disk, -providing a clear diagnostic instead of silently producing empty output. This scenario is tested by -`CppGenerator_Generate_NonexistentIncludeRoot_ThrowsDirectoryNotFoundException`. - -**Valid headers create the api entrypoint**: Verifies that the generator creates the `api` key in -the writer factory when run against the fixture headers, confirming the top-level entrypoint -generation path is wired correctly. This scenario is tested by -`CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint`. - -**Valid headers create a namespace summary page**: Verifies that a namespace summary page is created -for the fixture namespace, confirming that namespace discovery and page emission are correct. This -scenario is tested by `CppGenerator_Generate_ValidHeaders_CreatesNamespacePage`. - -**Valid headers create a type page for SampleClass**: Verifies that SampleClass defined in the -fixture headers receives a type page at the expected key, confirming that the ownership filter -and type page emission path both operate correctly. This scenario is tested by -`CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass`. - -**Deprecated class excluded by default**: Verifies that a class marked `[[deprecated]]` does not -receive a type page when IncludeDeprecated is false, confirming the default exclude behavior. This -scenario is tested by `CppGenerator_Generate_IncludeDeprecatedFalse_ExcludesDeprecatedClass`. - -**Deprecated class included when requested**: Verifies that a class marked `[[deprecated]]` receives -a type page when IncludeDeprecated is explicitly true, confirming the opt-in include behavior. This -scenario is tested by `CppGenerator_Generate_IncludeDeprecatedTrue_IncludesDeprecatedClass`. - -**Protected method excluded under Public visibility**: Verifies that a protected method does not -receive a member page when Visibility is Public, confirming that the access specifier filter -excludes non-public members correctly in the default mode. This scenario is tested by -`CppGenerator_Generate_PublicVisibility_ExcludesProtectedMethod`. - -**Protected method included under PublicAndProtected visibility**: Verifies that a protected method -receives its own member page when Visibility is PublicAndProtected, confirming the broader mode -includes protected members. This scenario is tested by -`CppGenerator_Generate_PublicAndProtectedVisibility_IncludesProtectedMethod`. - -**Private method included under All visibility**: Verifies that a private method receives its own -member page when Visibility is All, confirming the most permissive mode includes all access -specifiers. This scenario is tested by `CppGenerator_Generate_AllVisibility_IncludesPrivateMethod`. - -**Method with parameters creates a member page**: Verifies that a method with parameters receives -its own dedicated member detail page, confirming that parameterized methods are handled by the -member emission path. This scenario is tested by -`CppGenerator_Generate_MethodWithParameters_CreatesMemberPage`. - -**All members receive separate files**: Verifies that every visible member — parameterless methods, -methods with parameters, and free functions — receives a separate file, making navigation fully -deterministic. This scenario is tested by `CppGenerator_AllMembers_GetSeparateFiles`. - -**Output files follow naming convention**: Verifies that the generated file keys follow the -established naming convention: `api` for the entrypoint, `{namespace}` for namespace summaries, -`{namespace}/{TypeName}` for type pages, and `{namespace}/{TypeName}/{MemberName}` for member -detail pages. This scenario is tested by `CppGenerator_OutputFiles_FollowNamingConvention`. - -**api.md lists namespaces with declaration count**: Verifies that the api.md entrypoint lists all -namespaces with a Types column showing the count of classes, enums, and free functions directly -in each namespace, giving AI agents a complete navigation map and scope signal in one read. This -scenario is tested by `CppGenerator_Generate_ApiMd_ListsNamespacesWithTypeCount`. - -**Type with doc comment writes summary to paragraph**: Verifies that a Doxygen `@brief` comment -on a class is rendered as a description paragraph in the type page, confirming that doc comment -extraction and rendering are wired correctly. This scenario is tested by -`CppGenerator_Generate_TypeWithDocComment_WritesSummaryToParagraph`. - -**Method with doc comment writes summary to paragraph**: Verifies that a Doxygen `@brief` comment -on a method is rendered as a description paragraph in the member page, confirming that method-level -doc comments are extracted and rendered. This scenario is tested by -`CppGenerator_Generate_MethodWithDocComment_WritesSummaryToParagraph`. - -**Missing doc comment writes placeholder**: Verifies that a member with no Doxygen doc comment -emits the standard no-description placeholder rather than an empty or absent description field, -consistent with the DotNetGenerator behavior for missing XML doc entries. This scenario is tested -by `CppGenerator_Generate_MissingDocComment_WritesPlaceholder`. - -**Free functions receive their own pages**: Verifies that free functions in a namespace receive -dedicated pages at `{namespace}/{functionName}`, confirming that namespace-level functions are -treated as documented members with their own pages. This scenario is tested by -`CppGenerator_Generate_FreeFunctions_GetOwnPages`. - -**Valid headers create an enum page**: Verifies that an enum declared in the fixture headers -receives a type page at the expected key, confirming that enum types are handled by the ownership -filter and type page emission path. This scenario is tested by -`CppGenerator_Generate_ValidHeaders_CreatesEnumPage`. - -**Enum page contains all declared values**: Verifies that the enum type page includes all declared -enum value names in its output, confirming that enum member enumeration is complete. This scenario -is tested by `CppGenerator_Generate_EnumPage_ContainsValues`. - -**Template class creates a type page**: Verifies that a primary class template receives its own -type page, confirming that template declarations are handled by the ownership filter and type page -emission path. This scenario is tested by `CppGenerator_Generate_TemplateClass_CreatesTypePage`. - -**Inheritance class creates a type page**: Verifies that a class that inherits from another class -receives its own type page, confirming that derived types are documented independently of their -base class. This scenario is tested by `CppGenerator_Generate_InheritanceClass_CreatesTypePage`. - -**Constructor creates a constructor detail page**: Verifies that an explicit constructor receives -its own member detail page, confirming that constructors are treated as documented members. This -scenario is tested by `CppGenerator_Generate_Constructor_CreatesConstructorPage`. - -**Type page contains fully qualified C++ name**: Verifies that the type page signature block -contains the fully qualified C++ name so an AI reader knows exactly how to reference the type in -code without guessing the namespace prefix. This scenario is tested by -`CppGenerator_Generate_TypePage_ContainsQualifiedName`. - -**Member page contains fully qualified C++ name**: Verifies that the member page signature block -contains the fully qualified C++ name so an AI reader can call the member without needing to -resolve the namespace separately. This scenario is tested by -`CppGenerator_Generate_MemberPage_ContainsQualifiedName`. - -**Variadic function creates its own page**: Verifies that a variadic free function declared with -`...` receives its own dedicated page, confirming that variadic functions are handled correctly by -the free-function emission path. This scenario is tested by -`CppGenerator_Generate_VariadicFunction_CreatesPage`. - -**Case-collision class creates a combined page**: Verifies that members whose names differ only by -case are merged into a single combined detail page on case-insensitive targets. This scenario is -tested by `CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage`. - -**Case-collision class does not create a separate cased page**: Verifies that the generator does -not emit a second detail page for the same member name with different casing. This scenario is -tested by `CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage`. - -**Case-collision class combined page contains both members**: Verifies that the combined detail -page includes documentation for both case-colliding members. This scenario is tested by +**Case-collision members share one page**: Verifies that members whose names differ only by case +are emitted on a single combined detail page. This scenario is tested by +`CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage` and `CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers`. -**Final class emits final keyword in signature**: Verifies that when a class is declared `final`, -its type page signature block contains the `final` keyword in the class declaration line so that -readers can immediately identify the class as non-subclassable without opening the header file. -This scenario is tested by `CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature`. - -**Non-final class does not emit final keyword**: Verifies that a class not declared `final` does -not have the `final` keyword anywhere in its type page signature block, confirming that the -annotation is only applied when explicitly declared. This scenario is tested by -`CppGenerator_Generate_NonFinalClass_DoesNotEmitFinalKeyword`. - -**Class with operator overloads creates operators page**: Verifies that a class declaring -`operator+` and `operator==` produces a single shared `operators.md` page at -`{namespace}/{TypeName}/operators` rather than individual colliding pages. This prevents the -file-name collision that arises because multiple operator names sanitize to the same safe file -name. This scenario is tested by -`CppGenerator_Generate_ClassWithOperators_CreatesOperatorsPage`. - -**Operators page contains operator entry**: Verifies that the operators page for a class with -overloads contains a heading for each declared operator, confirming all overloads are documented -on the combined page. This scenario is tested by -`CppGenerator_Generate_ClassWithOperators_OperatorsPageContainsOperatorEntry`. - -**Type page links to operators page**: Verifies that the type page for a class with operator -overloads contains a link to `operators.md` in its Operators section, allowing readers to -navigate from the type overview to the operator detail page. This scenario is tested by -`CppGenerator_Generate_ClassWithOperators_TypePageLinksToOperatorsPage`. - -**Namespace free operator creates namespace operators page**: Verifies that a namespace-level -operator free function (e.g. `operator<<`) produces a shared `operators.md` page at -`{namespace}/operators` rather than an individual page that would collide with other namespace -operators. This scenario is tested by +**Namespace operators share one page**: Verifies that namespace-level operator overloads are +combined onto one operators page. This scenario is tested by `CppGenerator_Generate_NamespaceFreeOperator_CreatesNamespaceOperatorsPage`. -**Transitive-include symbols from non-selected headers are excluded**: Verifies that when a -selected header transitively includes another header that is under a PublicIncludeRoot but was not -selected by `--api-headers`, symbols defined in the non-selected header are excluded from the -generated output. This confirms that ownership requires both root-membership and header selection, -preventing dependency types from appearing in the docs. This scenario is tested by -`CppGenerator_Generate_ApiHeaderPatterns_TransitiveInclude_ExcludesNonSelectedSymbols`. - -**No api-header patterns documents all headers**: Verifies that when `ApiHeaderPatterns` is empty, -all headers with recognized C++ extensions under the configured PublicIncludeRoots are documented -without any pattern filtering. This scenario is tested by -`CppGenerator_Generate_NoApiHeaderPatterns_DocumentsAllHeaders`. - -**Include pattern restricts documented headers**: Verifies that a specific include pattern (e.g. -`**/SampleClass.h`) restricts header enumeration so only files matching the pattern are documented; -headers not matching the pattern are excluded. This scenario is tested by -`CppGenerator_Generate_ApiHeaderPatterns_IncludePattern_OnlyMatchingFilesDocumented`. - -**Exclusion pattern excludes matching headers**: Verifies that a `!`-prefixed exclusion pattern -excludes matching headers while headers not matching the exclusion pattern are still documented. -This scenario is tested by -`CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles`. - -**Re-include pattern overrides earlier exclusion (gitignore semantics)**: Verifies that a header -excluded by a `!`-prefixed pattern is re-included when a subsequent positive pattern matches it. -Last-pattern-wins (gitignore) semantics are confirmed by this scenario, which is tested by -`CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader`. - -**Exclusion without re-include permanently excludes header**: Verifies that a header excluded by a -`!`-prefixed pattern with no subsequent positive pattern remains excluded from the generated -output, confirming that the last matching pattern wins. This scenario is tested by -`CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_ExcludesHeader`. - -**CWD-relative pattern selects only matching files**: Verifies that a relative pattern resolved -from the current working directory (e.g. a path of the form -`test/ApiMark.Cpp.Fixtures/include/fixtures/SampleClass.h` when invoked from the repo root) -selects only the file it names and no other headers. This scenario would fail with the old -include-root-expansion behavior because that behavior joined the pattern onto each include root, -producing a doubled path that matches nothing. This scenario is tested by -`CppGenerator_Generate_ApiHeaderPatterns_CwdRelativePattern_OnlyMatchingFilesDocumented`. - -**CWD-relative exclusion pattern removes matching files**: Verifies that a `!`-prefixed relative -pattern resolved from the current working directory removes the named file from the documented -header set while leaving all other headers present. This scenario confirms that CWD-relative -resolution applies consistently to both inclusion and exclusion patterns. This scenario is tested -by `CppGenerator_Generate_ApiHeaderPatterns_CwdRelativeExclusionPattern_ExcludesMatchingFiles`. - -**Intra-library return type emits a Markdown link in the Returns cell**: Verifies that when a -method returns a type that is itself documented within the same library, the Returns column in the -Methods table contains a Markdown link to that type's page rather than plain text. This gives AI -readers navigable links for cross-type traversal. This scenario is tested by -`CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell`. - -**Unknown namespaced type is tracked as external**: Verifies that when `CppTypeLinkResolver` -resolves a type whose namespace is not in the known-types dictionary, the original type string is -returned as plain text (no broken link) and the type is recorded in the external types tracking -set with its namespace and type name. This prevents broken links and enables the External Types -section to enumerate all referenced-but-undocumented types. This scenario is tested by -`CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType`. - -**Single-file output writes a complete api.md tree**: Verifies that when `OutputFormat.SingleFile` -is configured, the generator produces exactly one writer keyed `api`, containing an H1 library -name heading, H2 namespace heading, H3 class heading (e.g., `SampleClass`), H4 member headings -with parentheses, no group headings (`Constructors`, `Methods`), and at least one compact -bullet-list paragraph (`- **MemberName**: description`) summarizing a class's members. -This scenario is tested by -`CppGenerator_Generate_SingleFileOutput_WritesSingleApiMarkdown`. - -**Deleted copy constructor emits = delete suffix**: Verifies that a copy constructor declared -`= delete` in the header is documented with a `= delete` suffix in its generated signature, -making the intentional prohibition visible to readers without requiring them to open the header. -This scenario is tested by `CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix`. - -**Deleted copy assignment operator emits = delete suffix**: Verifies that a copy-assignment -operator declared `= delete` is documented with a `= delete` suffix in its signature, -confirming that deleted operators carry the prohibition annotation. -This scenario is tested by -`CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix`. - -**Type alias creates alias pages**: Verifies that `using` type aliases declared in documented -namespaces receive their own dedicated pages, confirming that aliases are treated as first-class -documented entities. -This scenario is tested by `CppGenerator_Generate_TypeAlias_CreatesAliasPages`. +**Class-scoped type aliases are documented**: Verifies that class-level aliases receive pages and +are listed from the owning class page. This scenario is tested by +`CppGenerator_Generate_ClassScopedTypeAlias_CreatesAliasPage` and +`CppGenerator_Generate_ClassScopedTypeAlias_ListedOnClassPage`. -**Type alias page contains declaration and summary**: Verifies that the alias page contains both -the `using` declaration and the Doxygen summary comment, providing both the type information and -the documentation. -This scenario is tested by `CppGenerator_Generate_TypeAliasPage_ContainsDeclarationAndSummary`. +**CWD-relative header patterns are supported**: Verifies that relative `ApiHeaderPatterns` are +resolved from the current working directory. This scenario is tested by +`CppGenerator_Generate_ApiHeaderPatterns_CwdRelativePattern_OnlyMatchingFilesDocumented` and +`CppGenerator_Generate_ApiHeaderPatterns_CwdRelativeExclusionPattern_ExcludesMatchingFiles`. -**Namespace page lists type aliases**: Verifies that the namespace summary page lists owned type -aliases so readers can discover them without visiting each alias page individually. -This scenario is tested by `CppGenerator_Generate_NamespacePage_ListsTypeAliases`. +**Null context rejected by Parse**: Verifies that passing a null `IContext` to +`CppGenerator.Parse` throws `ArgumentNullException` before any file I/O is attempted. +Tested by `CppGenerator_Parse_NullContext_ThrowsArgumentNullException`. -**Type alias page simplifies underlying type**: Verifies that verbose underlying type names are -simplified in the alias page declaration, consistent with how other type names are simplified -throughout the generated documentation. -This scenario is tested by `CppGenerator_Generate_TypeAliasPage_SimplifiesUnderlyingType`. - -**Default parameter signature contains default value**: Verifies that a function parameter with -a default value has the default displayed in the generated signature block. -This scenario is tested by `CppGenerator_Generate_DefaultParameter_SignatureContainsDefault`. - -**Bool default parameter signature contains false**: Verifies that a `bool` parameter defaulted -to `false` shows `false` in the generated signature. -This scenario is tested by `CppGenerator_Generate_BoolDefaultParameter_SignatureContainsFalse`. - -**Negative int default parameter signature contains negative value**: Verifies that an integer -parameter defaulted to a negative value shows the negative value in the generated signature. -This scenario is tested by -`CppGenerator_Generate_NegativeIntDefaultParameter_SignatureContainsNegativeValue`. - -**Float default parameter signature contains value**: Verifies that a float parameter with a -default value shows the value in the generated signature. -This scenario is tested by `CppGenerator_Generate_FloatDefaultParameter_SignatureContainsValue`. - -**Nested class creates a nested class page**: Verifies that a class declared inside another class -receives its own type page, confirming that nested type declarations are documented independently. -This scenario is tested by `CppGenerator_Generate_NestedClass_CreatesNestedClassPage`. - -**Nested class is listed on outer class page**: Verifies that the outer class type page lists -its nested class in a Nested Types section, providing navigation from the containing type to -its nested types. -This scenario is tested by `CppGenerator_Generate_NestedClass_ListedOnOuterClassPage`. - -**Class-scoped type alias creates alias page**: Verifies that a `using` type alias declared -inside a class receives its own page, confirming that class-scoped aliases are treated as -first-class documented entities. -This scenario is tested by `CppGenerator_Generate_ClassScopedTypeAlias_CreatesAliasPage`. - -**Class-scoped type alias listed on class page**: Verifies that the class type page lists its -scoped type aliases, providing navigation from the type page to alias pages. -This scenario is tested by `CppGenerator_Generate_ClassScopedTypeAlias_ListedOnClassPage`. - -**Class-scoped type alias does not collide across classes**: Verifies that same-named type -aliases in different classes do not collide in the generated output, confirming that -namespace-and-class-scoped paths are used for alias page keys. -This scenario is tested by -`CppGenerator_Generate_ClassScopedTypeAlias_DoesNotCollideAcrossClasses`. - -**Method with code example emits code block on member page**: Verifies that a method whose -Doxygen comment includes a `@code`/`@endcode` block produces a fenced code block on its -member detail page. -This scenario is tested by `CppGenerator_Generate_MethodWithCodeExample_EmitsCodeBlockOnMemberPage`. - -**Single-file method with code example emits code block**: Verifies that in single-file output -mode, a method with a Doxygen code example produces a fenced code block in the single api.md file. -This scenario is tested by `CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock`. - -### Unit Test Scenarios - -**CppSourceLocation stores file and line correctly**: Verifies that `CppSourceLocation` records the -`File` and `Line` values passed at construction, confirming that source location information is -preserved for use in `#include` directives and diagnostic messages on generated pages. This -scenario is tested by `CppSourceLocation_Construction_SetsFileAndLine`. - -**CppParamDoc stores name and description correctly**: Verifies that `CppParamDoc` records the -parameter name and description passed at construction, confirming that per-parameter documentation -extracted from Doxygen `@param` blocks is preserved for rendering in parameter tables. This -scenario is tested by `CppParamDoc_Construction_SetsNameAndDescription`. - -**CppDocComment stores summary and details correctly**: Verifies that `CppDocComment` records the -summary and details strings passed at construction, confirming that `@brief` and `@details` content -is preserved for rendering as description and extended-details paragraphs. This scenario is tested -by `CppDocComment_Construction_SetsSummaryAndDetails`. - -**Two identical CppDocComment instances are equal**: Verifies that record equality holds for -`CppDocComment`, confirming that the record semantics do not require reference identity for -documentation comparison or deduplication logic. This scenario is tested by -`CppDocComment_Equality_TwoIdenticalInstances_AreEqual`. - -**CppBaseType stores name correctly**: Verifies that `CppBaseType` records the base class name -passed at construction, confirming that inheritance information is preserved for rendering in type -signature blocks. This scenario is tested by `CppBaseType_Construction_SetsName`. - -**CppTemplateParam stores name correctly**: Verifies that `CppTemplateParam` records the template -parameter name passed at construction, confirming that template parameter information is preserved -for rendering in type signature blocks. This scenario is tested by -`CppTemplateParam_Construction_SetsName`. - -**CppEnumValue stores name and documentation correctly**: Verifies that `CppEnumValue` records the -name and doc comment passed at construction, confirming that enum value names and their -documentation are preserved for rendering in enum value tables. This scenario is tested by -`CppEnumValue_Construction_SetsNameAndDoc`. - -**CppParameter stores name and type name correctly**: Verifies that `CppParameter` records the -parameter name and type name passed at construction, confirming that function parameter information -is preserved for rendering in parameter signature blocks. This scenario is tested by -`CppParameter_Construction_SetsNameAndTypeName`. - -**CppParameter default value is null when not provided**: Verifies that `CppParameter.DefaultValue` -is null when no default value is passed at construction, confirming that the default-value field is -optional and correctly absent for parameters without defaults. This scenario is tested by -`CppParameter_DefaultValue_WhenNotProvided_IsNull`. - -**CppField stores core properties correctly**: Verifies that `CppField` records the name, type name, -accessibility, and static flag passed at construction, confirming that field metadata is preserved -for use in visibility filtering and field signature rendering. This scenario is tested by -`CppField_Construction_SetsCoreProperties`. - -**CppFunction stores core properties correctly**: Verifies that `CppFunction` records the name, -return type, accessibility, and constructor flag passed at construction, confirming that function -metadata is preserved for use in visibility filtering, constructor detection, and signature -rendering. This scenario is tested by `CppFunction_Construction_SetsCoreProperties`. - -**CppClass stores core properties correctly**: Verifies that `CppClass` records the name, base -types, and final flag passed at construction, confirming that class metadata is preserved for use -in ownership filtering, inheritance rendering, and page generation. This scenario is tested by -`CppClass_Construction_SetsCoreProperties`. - -**CppEnum stores name and values correctly**: Verifies that `CppEnum` records the name and value -list passed at construction, confirming that enum type metadata is preserved for rendering in enum -pages. This scenario is tested by `CppEnum_Construction_SetsNameAndValues`. - -**CppTypeAlias stores name and underlying type correctly**: Verifies that `CppTypeAlias` records -the alias name and underlying type name passed at construction, confirming that type alias metadata -is preserved for rendering in alias pages. This scenario is tested by -`CppTypeAlias_Construction_SetsNameAndUnderlyingType`. - -**CppNamespaceDecl stores qualified name correctly**: Verifies that `CppNamespaceDecl` records the -fully qualified namespace name passed at construction, confirming that the namespace key used for -page naming and dictionary lookups is preserved. This scenario is tested by -`CppNamespaceDecl_Construction_SetsQualifiedName`. - -**CppCompilationResult stores namespaces and errors correctly**: Verifies that -`CppCompilationResult` records both the namespace declarations and error messages passed at -construction, confirming that the complete parse output is preserved for consumption by the emitter -and diagnostic reporting. This scenario is tested by -`CppCompilationResult_Construction_SetsNamespacesAndErrors`. - -**CppAccessibility enum contains Public, Protected, and Private values**: Verifies that the -`CppAccessibility` enum declares the three expected access specifiers, confirming that all C++ -access levels required by the visibility filter are represented. This scenario is tested by -`CppAccessibility_Values_ArePublicProtectedPrivate`. - -**CppEmitter rejects a null factory with an ArgumentNullException**: Verifies that calling -`CppEmitter.Emit` with a null factory throws `ArgumentNullException` before any I/O is attempted, -providing a clear failure rather than a misleading null-reference error from within a file-write -operation. This scenario is tested by `CppEmitter_Emit_NullFactory_ThrowsArgumentNullException`. - -**CppEmitter dispatches GradualDisclosure format to produce multiple files**: Verifies that when -`OutputFormat.GradualDisclosure` is configured the emitter produces more than one Markdown writer, -confirming that the dispatch path routes to the gradual-disclosure emitter. This scenario is tested -by `CppEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles`. - -**CppEmitter dispatches SingleFile format to produce exactly one api file**: Verifies that when -`OutputFormat.SingleFile` is configured the emitter produces exactly one writer keyed `api`, -confirming that the dispatch path routes to the single-file emitter. This scenario is tested by -`CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile`. - -**CppEmitter SanitizeFileName leaves regular names unchanged**: Verifies that -`CppEmitter.SanitizeFileName` returns the original name unchanged when it contains no -file-system-invalid characters, confirming that the sanitizer does not corrupt well-formed -identifiers. This scenario is tested by `CppEmitter_SanitizeFileName_RegularName_IsUnchanged`. - -**CppEmitter BuildClassDeclaration returns class name for a non-final class with no bases**: -Verifies that `CppEmitter.BuildClassDeclaration` returns `class ClassName` for a class that is -neither final nor derived, confirming that the minimal class declaration is emitted without -unnecessary keywords. This scenario is tested by -`CppEmitter_BuildClassDeclaration_NonFinalNoBase_ReturnsJustClassName`. - -**CppEmitter BuildClassDeclaration appends final keyword for a final class**: Verifies that -`CppEmitter.BuildClassDeclaration` appends the `final` specifier to the class declaration when the -class is marked final, confirming that the non-subclassable constraint is visible in the generated -signature block. This scenario is tested by -`CppEmitter_BuildClassDeclaration_FinalClass_AppendsFinalKeyword`. - -**CppEmitterGradualDisclosure creates the api index page from minimal data**: Verifies that the -gradual-disclosure emitter creates the `api` writer key when constructed from a minimal -namespace-declarations dictionary (without invoking clang), confirming that the entrypoint page is -emitted correctly for any non-empty namespace set. This scenario is tested by -`CppEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage`. - -**CppEmitterGradualDisclosure creates a namespace page from minimal data**: Verifies that the -gradual-disclosure emitter creates a writer whose key contains the `testlib` namespace name, -confirming that namespace summary pages are emitted for all namespaces in the declarations -dictionary. This scenario is tested by -`CppEmitterGradualDisclosure_Emit_MinimalData_CreatesNamespacePage`. - -**CppEmitterGradualDisclosure creates a type page for Widget from minimal data**: Verifies that -the gradual-disclosure emitter creates a writer whose key contains `Widget`, confirming that -per-type pages are emitted for all classes in each namespace. This scenario is tested by -`CppEmitterGradualDisclosure_Emit_MinimalData_CreatesTypePage`. - -**CppEmitterGradualDisclosure api index page heading contains the library name**: Verifies that -the api index page includes a heading containing the configured library name, confirming that the -top-level heading identifies the documented library. This scenario is tested by -`CppEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibraryNameHeading`. - -**CppEmitterSingleFile creates exactly one writer from minimal data**: Verifies that the -single-file emitter produces exactly one Markdown writer when constructed from a minimal -namespace-declarations dictionary (without invoking clang), confirming that all documentation is -consolidated into a single file. This scenario is tested by -`CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter`. - -**CppEmitterSingleFile creates only the api writer from minimal data**: Verifies that the single -writer produced by the single-file emitter is keyed as `api`, confirming that the output file name -follows the established convention for single-file mode. This scenario is tested by -`CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly`. - -**CppEmitterSingleFile api file contains a library-name heading**: Verifies that the single output -file includes a heading containing the configured library name, confirming that the top-level -section identifies the documented library. This scenario is tested by -`CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHeading`. - -**CppEmitterSingleFile api file contains a namespace-level heading**: Verifies that the single -output file includes a heading containing the namespace name, confirming that namespaces are -represented as sections in the consolidated document. This scenario is tested by -`CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeading`. - -**ClangAstParser returns non-empty namespaces for fixture headers**: Verifies that parsing the -fixture header files via clang produces at least one namespace, confirming that the clang -invocation and JSON AST parsing pipeline successfully extracts namespace declarations. This -scenario is tested by `ClangAstParser_Parse_FixtureHeaders_ReturnsNonEmptyNamespaces`. - -**ClangAstParser produces the fixtures namespace from fixture headers**: Verifies that parsing -the fixture header files produces a namespace whose qualified name contains `fixtures`, confirming -that the correct namespace is extracted and its key is correctly formed. This scenario is tested by -`ClangAstParser_Parse_FixtureHeaders_ContainsFixturesNamespace`. +**Ownership filtering limited to configured public roots**: Verifies that only declarations +whose source file falls under a configured `PublicIncludeRoot` appear in the generated +output. Tested by `CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass` and +`CppGenerator_Generate_ApiHeaderPatterns_TransitiveInclude_ExcludesNonSelectedSymbols`. -**ClangAstParser produces a SampleClass in the fixtures namespace**: Verifies that the `fixtures` -namespace produced by parsing the fixture headers contains a class named `SampleClass`, confirming -that class declarations in a specific namespace are correctly attributed to that namespace by the -AST parser. This scenario is tested by -`ClangAstParser_Parse_FixtureHeaders_FixturesNamespaceContainsSampleClass`. +**Intra-library type references emit Markdown links**: Verifies that a known intra-library +type referenced in a member signature produces a Markdown hyperlink in the generated table +cell. Tested by `CppGenerator_Generate_TypeLinkInMemberSignature_EmitsMarkdownLink`. -**ClangAstParser extracts member declarations for SampleClass**: Verifies that the `SampleClass` -parsed from the fixture headers has non-empty members, confirming that member declarations within a -class body are correctly extracted and associated with the class. This scenario is tested by -`ClangAstParser_Parse_FixtureHeaders_SampleClassHasMembers`. +**Deleted notation on type pages**: Verifies that a method declared `= delete` is annotated +as deleted on its generated page. Tested by +`CppGenerator_Generate_DeletedMember_ShowsDeletedNotation`. diff --git a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md index 123aab1..4de6602 100644 --- a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md @@ -49,3 +49,30 @@ page even when two types share the same unqualified name, because the exact-matc takes precedence over the ambiguous short-name path. This scenario is tested by `CppTypeLinkResolver_Linkify_QualifiedReferenceToAmbiguousType_EmitsCorrectLink`. + +**Primitive type returns unchanged**: Verifies that primitive C++ types such as `int` are +returned as plain text without being tracked as external types. Tested by +`CppTypeLinkResolver_Linkify_PrimitiveType_ReturnsUnchanged`. + +**Null input returns null**: Verifies that passing a null type string returns null +without throwing. Tested by `CppTypeLinkResolver_Linkify_NullInput_ReturnsNull`. + +**Whitespace input returns unchanged**: Verifies that a whitespace-only string is returned +unchanged. Tested by `CppTypeLinkResolver_Linkify_WhitespaceInput_ReturnsUnchanged`. + +**std:: type returns unchanged**: Verifies that `std::` types are returned as plain text +and are not tracked as external types. Tested by +`CppTypeLinkResolver_Linkify_StdType_ReturnsUnchanged`. + +**External namespaced type is tracked**: Verifies that a non-std, non-library external type +with a namespace qualifier is added to the caller's external-types set. Tested by +`CppTypeLinkResolver_Linkify_ExternalType_AddsToExternalTypesSet`. + +**Qualifiers stripped before lookup**: Verifies that leading `const`/`volatile` and trailing +reference/pointer qualifiers are removed before the base name is looked up. Tested by +`CppTypeLinkResolver_Linkify_QualifiedType_StripsQualifiersBeforeLookup`. + +**Template arg prefix not corrupted**: Verifies that when a type name appears as both an +intra-library type (e.g. `Foo`) and a prefix of a template argument (e.g. `FooBar`), only +the actual type token is linked and the template argument is left unchanged. Tested by +`CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplateArg_EmitsLinkWithoutCorruption`. diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index 440ae45..1e42ce6 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -104,6 +104,13 @@ public static CppCompilationResult Parse( IReadOnlyList headers, CppGeneratorOptions options) { + ArgumentNullException.ThrowIfNull(headers); + ArgumentNullException.ThrowIfNull(options); + if (headers.Count == 0 || headers.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("At least one non-empty header path is required.", nameof(headers)); + } + // Resolve the clang executable (may be xcrun on macOS) var (fileName, prefix) = FindClangExecutable(options.ClangPath); diff --git a/src/ApiMark.Cpp/CppAst/CppAstModel.cs b/src/ApiMark.Cpp/CppAst/CppAstModel.cs index a53841c..4bfdd6d 100644 --- a/src/ApiMark.Cpp/CppAst/CppAstModel.cs +++ b/src/ApiMark.Cpp/CppAst/CppAstModel.cs @@ -1,6 +1,14 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + namespace ApiMark.Cpp.CppAst; /// Specifies the access level of a C++ class member. +/// +/// The CppAstModel record and enum types are immutable value objects populated by +/// and then shared across the emitters. They are safe for +/// concurrent read access after construction because their exposed state does not mutate. +/// public enum CppAccessibility { /// The member is publicly accessible. diff --git a/src/ApiMark.Cpp/CppEmitter.cs b/src/ApiMark.Cpp/CppEmitter.cs index 9f78150..6b14805 100644 --- a/src/ApiMark.Cpp/CppEmitter.cs +++ b/src/ApiMark.Cpp/CppEmitter.cs @@ -128,6 +128,7 @@ public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext con /// internal static string SanitizeFileName(string name) { + ArgumentNullException.ThrowIfNull(name); var invalidChars = Path.GetInvalidFileNameChars(); var chars = name.ToCharArray(); for (var i = 0; i < chars.Length; i++) diff --git a/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs b/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs index 94fb950..45ec03c 100644 --- a/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs +++ b/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs @@ -18,6 +18,8 @@ internal sealed class CppEmitterGradualDisclosure ["Namespace", "`{Namespace}.md`"], ["Type", "`{Namespace}/{TypeName}.md`"], ["Member", "`{Namespace}/{TypeName}/{MemberName}.md`"], + ["Nested type", "`{Namespace}/{OuterType}/{NestedType}.md`"], + ["Class-scoped type alias", "`{Namespace}/{TypeName}/{AliasName}.md`"], ["Free function", "`{Namespace}/{FunctionName}.md`"], ["Enum", "`{Namespace}/{EnumName}.md`"], ["Type alias", "`{Namespace}/{AliasName}.md`"], @@ -152,7 +154,7 @@ private void WriteApiPage( var rows = namespaces.Select(kv => { var nsDecls = kv.Value; - var declarationCount = nsDecls.Classes.Count + nsDecls.Enums.Count + nsDecls.FreeFunctions.Count; + var declarationCount = nsDecls.Classes.Count + nsDecls.Enums.Count + nsDecls.FreeFunctions.Count + nsDecls.TypeAliases.Count; var description = CppEmitter.GetNamespaceDescription(nsDecls); return new[] { $"[{nsDecls.DisplayName}]({kv.Key}.md)", declarationCount.ToString(), description }; }); @@ -831,7 +833,7 @@ internal static void WriteFunctionContent( // Linkify parameter type cells; resolver tracks external types encountered var paramRows = method.Parameters.Select(p => - new[] { p.Name, ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(p.TypeName), ctx.CurrentFolder, ctx.ExternalTypes), CppEmitter.GetParamDescription(method.Doc, p.Name) ?? string.Empty }); + new[] { p.Name, ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(p.TypeName), ctx.CurrentFolder, ctx.ExternalTypes), CppEmitter.GetParamDescription(method.Doc, p.Name) ?? CppEmitter.NoDescriptionPlaceholder }); writer.WriteTable(paramHeaders, paramRows); } diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index e280cf3..00293b7 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -64,7 +64,7 @@ public CppGenerator(CppGeneratorOptions options) /// Enumerate candidate header files under each entry. /// Run clang with -ast-dump=json on all candidate headers via . /// Log any clang diagnostic errors from system headers via the context output channel. - /// Walk the parsed namespaces, applying the ownership and visibility filters. + /// Walk the parsed namespaces, applying the ownership filter and deprecated filter. /// /// The caller must subsequently invoke to write output. /// @@ -72,6 +72,7 @@ public CppGenerator(CppGeneratorOptions options) /// Output channel for informational messages. Must not be null. System-header diagnostic /// messages from clang are emitted here via . /// + /// Thrown when is null. /// /// Thrown when is empty and a /// path in does not exist on disk. @@ -81,6 +82,8 @@ public CppGenerator(CppGeneratorOptions options) /// public IApiEmitter Parse(IContext context) { + ArgumentNullException.ThrowIfNull(context); + // Collect candidate header files from all configured public include roots var headerFiles = CollectHeaderFiles(); diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index 02cdff4..07c5512 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -70,6 +70,7 @@ internal sealed class CppTypeLinkResolver /// public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) { + ArgumentNullException.ThrowIfNull(knownTypes); _knownTypes = knownTypes; } @@ -80,7 +81,7 @@ public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) /// /// /// The simplified C++ type string to resolve (e.g. "const fixtures::SampleClass &"). - /// Must not be null. + /// When null or whitespace, the value is returned unchanged. /// /// /// The folder path of the Markdown file that will contain the link, relative to the @@ -141,7 +142,31 @@ public string Linkify( ? stripped[(stripped.LastIndexOf("::", StringComparison.Ordinal) + 2)..] : stripped; var linked = $"[{shortName}]({relativePath})"; - return cppTypeString.Replace(shortName, linked, StringComparison.Ordinal); + + // Position-aware single-site replacement: locate shortName at the exact token + // position to avoid corrupting template arguments that share a prefix with it + // (e.g. Foo where shortName = "Foo" must not also linkify "FooBar") + int startIdx; + if (stripped.Contains("::", StringComparison.Ordinal)) + { + // For qualified types, find the last '::' in the original string so we + // start searching only where the unqualified name begins + var lastColonPos = cppTypeString.LastIndexOf("::", StringComparison.Ordinal); + startIdx = lastColonPos >= 0 ? lastColonPos + 2 : 0; + } + else + { + startIdx = 0; + } + + var idx = cppTypeString.IndexOf(shortName, startIdx, StringComparison.Ordinal); + if (idx < 0) + { + // Fallback: should never happen in practice, but avoid silent corruption + return cppTypeString; + } + + return cppTypeString[..idx] + linked + cppTypeString[(idx + shortName.Length)..]; } // External type with a namespace: track for the External Types section @@ -209,21 +234,23 @@ internal static string StripQualifiers(string typeString) { var s = typeString.Trim(); - // Remove leading cv-qualifiers - if (s.StartsWith("const ", StringComparison.Ordinal)) + while (true) { - s = s[6..].TrimStart(); - } + var previous = s; - if (s.StartsWith("volatile ", StringComparison.Ordinal)) - { - s = s[9..].TrimStart(); - } + // Remove leading cv-qualifiers until no more remain, regardless of order + if (s.StartsWith("const ", StringComparison.Ordinal)) + { + s = s[6..].TrimStart(); + } - // Remove trailing reference, pointer, and trailing-const qualifiers iteratively - s = s.TrimEnd(); - while (true) - { + if (s.StartsWith("volatile ", StringComparison.Ordinal)) + { + s = s[9..].TrimStart(); + } + + // Remove trailing reference, pointer, and trailing cv-qualifiers iteratively + s = s.TrimEnd(); if (s.EndsWith(" &&", StringComparison.Ordinal)) { s = s[..^3].TrimEnd(); continue; } if (s.EndsWith("&&", StringComparison.Ordinal)) { s = s[..^2].TrimEnd(); continue; } if (s.EndsWith(" &", StringComparison.Ordinal)) { s = s[..^2].TrimEnd(); continue; } @@ -231,8 +258,12 @@ internal static string StripQualifiers(string typeString) if (s.EndsWith(" *", StringComparison.Ordinal)) { s = s[..^2].TrimEnd(); continue; } if (s.EndsWith('*')) { s = s[..^1].TrimEnd(); continue; } if (s.EndsWith(" const", StringComparison.OrdinalIgnoreCase)) { s = s[..^6].TrimEnd(); continue; } + if (s.EndsWith(" volatile", StringComparison.OrdinalIgnoreCase)) { s = s[..^9].TrimEnd(); continue; } - break; + if (s == previous) + { + break; + } } // Remove template arguments — base name is everything before the first '<' diff --git a/test/ApiMark.Cpp.Tests/ClangAstParserTests.cs b/test/ApiMark.Cpp.Tests/ClangAstParserTests.cs index 40ef7e7..9738bdb 100644 --- a/test/ApiMark.Cpp.Tests/ClangAstParserTests.cs +++ b/test/ApiMark.Cpp.Tests/ClangAstParserTests.cs @@ -50,6 +50,52 @@ private static bool IsClangAvailable() PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], }; + /// Validates that null headers are rejected before any clang discovery occurs. + [Fact] + public void ClangAstParser_Parse_NullHeaders_ThrowsArgumentNullException() + { + // Arrange + var options = BuildOptions(); + + // Act / Assert + Assert.Throws(() => ClangAstParser.Parse(null!, options)); + } + + /// Validates that a null options argument is rejected before any clang discovery occurs. + [Fact] + public void ClangAstParser_Parse_NullOptions_ThrowsArgumentNullException() + { + // Arrange: a non-empty header list that would otherwise be valid + var headers = new List { "placeholder.h" }; + + // Act / Assert: null options must be rejected immediately + Assert.Throws(() => ClangAstParser.Parse(headers, null!)); + } + + /// Validates that an empty header list is rejected before any clang discovery occurs. + [Fact] + public void ClangAstParser_Parse_EmptyHeaders_ThrowsArgumentException() + { + // Arrange + var options = BuildOptions(); + + // Act / Assert + Assert.Throws(() => ClangAstParser.Parse([], options)); + } + + /// Validates that an invalid explicit clang path produces a clear . + [Fact] + public void ClangAstParser_Parse_InvalidExplicitClangPath_ThrowsInvalidOperationException() + { + // Arrange + var options = BuildOptions(); + options.ClangPath = Path.Join(Path.GetTempPath(), $"missing-clang-{Guid.NewGuid():N}.exe"); + var headers = Directory.GetFiles(FixturePaths.GetFixtureNamespaceDir(), "*.h").ToList(); + + // Act / Assert + Assert.Throws(() => ClangAstParser.Parse(headers, options)); + } + /// Validates that parsing fixture headers returns non-empty namespaces. [Fact] public void ClangAstParser_Parse_FixtureHeaders_ReturnsNonEmptyNamespaces() @@ -139,4 +185,25 @@ public void ClangAstParser_Parse_FixtureHeaders_SampleClassHasMembers() Assert.NotNull(sampleClass); Assert.NotEmpty(sampleClass.Members); } + + /// Validates that parsing well-formed fixture headers produces an empty errors collection. + [Fact] + public void ClangAstParser_Parse_FixtureHeaders_ErrorsCollectionIsEmpty() + { + // Skip when clang is not available + if (!IsClangAvailable()) + { + Assert.Skip("clang is not available on this system."); + } + + // Arrange + var options = BuildOptions(); + var headers = Directory.GetFiles(FixturePaths.GetFixtureNamespaceDir(), "*.h").ToList(); + + // Act + var result = ClangAstParser.Parse(headers, options); + + // Assert: clean fixture headers must not produce any error-class diagnostics + Assert.Empty(result.Errors); + } } diff --git a/test/ApiMark.Cpp.Tests/CppAstModelTests.cs b/test/ApiMark.Cpp.Tests/CppAstModelTests.cs index b24e397..8ed5b14 100644 --- a/test/ApiMark.Cpp.Tests/CppAstModelTests.cs +++ b/test/ApiMark.Cpp.Tests/CppAstModelTests.cs @@ -57,6 +57,18 @@ public void CppDocComment_Equality_TwoIdenticalInstances_AreEqual() Assert.Equal(doc1, doc2); } + /// Validates that Note and Example default to null when not provided. + [Fact] + public void CppDocComment_NoteAndExample_WhenNotProvided_AreNull() + { + // Arrange / Act + var doc = new CppDocComment("Summary.", null, [], null); + + // Assert + Assert.Null(doc.Note); + Assert.Null(doc.Example); + } + /// Validates that stores Name correctly. [Fact] public void CppBaseType_Construction_SetsName() @@ -148,6 +160,7 @@ public void CppFunction_Construction_SetsCoreProperties() Assert.Equal("int", fn.ReturnTypeName); Assert.Equal(CppAccessibility.Public, fn.Accessibility); Assert.False(fn.IsConstructor); + Assert.False(fn.IsDeleted); } /// Validates that stores core properties correctly. diff --git a/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs index 9b81ce3..8251e3a 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs @@ -13,25 +13,85 @@ namespace ApiMark.Cpp.Tests; /// Unit tests for . public class CppEmitterGradualDisclosureTests { - /// Builds a minimal set of namespace declarations for testing without invoking clang. - private static (CppEmitter emitter, SortedDictionary nsDecls, CppTypeLinkResolver resolver) BuildMinimalData() + /// Builds a representative synthetic API model without invoking clang. + private static (CppEmitter emitter, SortedDictionary nsDecls, CppTypeLinkResolver resolver) BuildRichData() { var options = new CppGeneratorOptions { LibraryName = "TestLib", PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], }; + + var widget = new CppClass( + "Widget", + [], + [], + [ + new CppFunction("GetValue", "int", [], CppAccessibility.Public, false, false, false, false, false, false, null, new CppDocComment("Gets the current value.", null, [], null)), + new CppFunction("operator+", "Widget", [new CppParameter("other", "Widget")], CppAccessibility.Public, false, false, false, false, false, false, null, new CppDocComment("Adds widgets.", null, [], "Combined widget.")), + ], + [], + [ + new CppClass("Nested", [], [], [], [], [], [], false, false, null, new CppDocComment("Nested widget type.", null, [], null)), + ], + [ + new CppTypeAlias("size_type", "std::size_t", false, null, new CppDocComment("Widget size alias.", null, [], null)), + ], + false, + false, + null, + new CppDocComment("A widget.", null, [], null)); + + var collisionClass = new CppClass( + "CaseCollisionClass", + [], + [], + [ + new CppFunction("Name", "int", [], CppAccessibility.Public, false, false, false, false, false, false, null, new CppDocComment("Gets the name.", null, [], null)), + ], + [ + new CppField("name", "int", CppAccessibility.Public, false, false, null, new CppDocComment("The stored name.", null, [], null)), + ], + [], + [], + false, + false, + null, + new CppDocComment("Collision fixture.", null, [], null)); + var nsDecls = new SortedDictionary(StringComparer.Ordinal); var ns = new CppEmitter.NamespaceDeclarations("testlib", new CppDocComment("A test library.", null, [], null)); - ns.Classes.Add(new CppClass("Widget", [], [], [], [], [], [], false, false, null, - new CppDocComment("A widget.", null, [], null))); + ns.Classes.Add(widget); + ns.Classes.Add(collisionClass); + ns.Enums.Add(new CppEnum("Color", [new CppEnumValue("Red", new CppDocComment("Red.", null, [], null))], false, null, new CppDocComment("Color options.", null, [], null))); + ns.TypeAliases.Add(new CppTypeAlias("widget_id_t", "int", false, null, new CppDocComment("Widget identifier.", null, [], null))); + ns.FreeFunctions.Add(new CppFunction("MakeWidget", "Widget", [], CppAccessibility.Public, false, false, false, false, false, false, null, new CppDocComment("Creates a widget.", null, [], "A widget."))); + ns.FreeFunctions.Add(new CppFunction("operator<<", "std::ostream &", [new CppParameter("stream", "std::ostream &"), new CppParameter("widget", "Widget")], CppAccessibility.Public, false, false, false, false, false, false, null, new CppDocComment("Streams a widget.", null, [], "The stream."))); nsDecls["testlib"] = ns; + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal) { { "testlib::Widget", "testlib/Widget" }, + { "testlib::Color", "testlib/Color" }, + { "testlib::widget_id_t", "testlib/widget_id_t" }, + { "testlib::Widget::Nested", "testlib/Widget/Nested" }, + { "testlib::Widget::size_type", "testlib/Widget/size_type" }, }); - var emitter = new CppEmitter(options, nsDecls, resolver); - return (emitter, nsDecls, resolver); + + return (new CppEmitter(options, nsDecls, resolver), nsDecls, resolver); + } + + /// Builds an emitter with no namespaces so the api page fallback path can be verified. + private static (CppEmitter emitter, SortedDictionary nsDecls, CppTypeLinkResolver resolver) BuildEmptyData() + { + var options = new CppGeneratorOptions + { + LibraryName = "EmptyLib", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + }; + var nsDecls = new SortedDictionary(StringComparer.Ordinal); + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + return (new CppEmitter(options, nsDecls, resolver), nsDecls, resolver); } /// Validates that the gradual-disclosure emitter creates the api index page. @@ -40,13 +100,13 @@ public void CppEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); - var (emitter, nsDecls, resolver) = BuildMinimalData(); + var (emitter, nsDecls, resolver) = BuildRichData(); // Act new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); // Assert - Assert.True(factory.HasWriter("", "api"), "Expected api index page to be created"); + Assert.True(factory.HasWriter("", "api")); } /// Validates that the gradual-disclosure emitter creates a namespace page. @@ -55,15 +115,13 @@ public void CppEmitterGradualDisclosure_Emit_MinimalData_CreatesNamespacePage() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); - var (emitter, nsDecls, resolver) = BuildMinimalData(); + var (emitter, nsDecls, resolver) = BuildRichData(); // Act new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); - // Assert: a namespace page exists for the "testlib" namespace key - Assert.True( - factory.Writers.Keys.Any(k => k.Contains("testlib", StringComparison.Ordinal)), - "Expected a namespace page containing 'testlib'"); + // Assert + Assert.True(factory.HasWriter("", "testlib")); } /// Validates that the gradual-disclosure emitter creates a type page for Widget. @@ -72,15 +130,13 @@ public void CppEmitterGradualDisclosure_Emit_MinimalData_CreatesTypePage() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); - var (emitter, nsDecls, resolver) = BuildMinimalData(); + var (emitter, nsDecls, resolver) = BuildRichData(); // Act new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); // Assert - Assert.True( - factory.Writers.Keys.Any(k => k.Contains("Widget", StringComparison.Ordinal)), - "Expected a type page containing 'Widget'"); + Assert.True(factory.HasWriter("testlib", "Widget")); } /// Validates that the api index page heading contains the library name. @@ -89,14 +145,150 @@ public void CppEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibrary { // Arrange var factory = new InMemoryMarkdownWriterFactory(); - var (emitter, nsDecls, resolver) = BuildMinimalData(); + var (emitter, nsDecls, resolver) = BuildRichData(); // Act new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); - // Assert: api page heading contains the library name - var apiWriter = factory.GetWriter("", "api"); - var headings = apiWriter.Operations.OfType().ToList(); + // Assert + var headings = factory.GetWriter("", "api").Operations.OfType().ToList(); Assert.Contains(headings, h => h.Text.Contains("TestLib", StringComparison.Ordinal)); } + + /// Validates that a method detail page is written for a regular class member. + [Fact] + public void CppEmitterGradualDisclosure_Emit_MethodMember_CreatesMemberDetailPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + Assert.True(factory.HasWriter("testlib/Widget", "GetValue")); + } + + /// Validates that a free-function detail page is written under the namespace folder. + [Fact] + public void CppEmitterGradualDisclosure_Emit_FreeFunction_CreatesFreeFunctionPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + Assert.True(factory.HasWriter("testlib", "MakeWidget")); + } + + /// Validates that enum declarations receive their own detail pages. + [Fact] + public void CppEmitterGradualDisclosure_Emit_Enum_CreatesEnumPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + Assert.True(factory.HasWriter("testlib", "Color")); + } + + /// Validates that type aliases receive their own detail pages. + [Fact] + public void CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + Assert.True(factory.HasWriter("testlib", "widget_id_t")); + } + + /// Validates that nested classes receive pages under their parent class folder. + [Fact] + public void CppEmitterGradualDisclosure_Emit_NestedClass_CreatesNestedClassPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + Assert.True(factory.HasWriter("testlib/Widget", "Nested")); + } + + /// Validates that colliding member names are merged onto a single lowercase page. + [Fact] + public void CppEmitterGradualDisclosure_Emit_CaseInsensitiveCollision_CreatesCombinedPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + Assert.True(factory.HasWriter("testlib/CaseCollisionClass", "name")); + Assert.False(factory.HasWriter("testlib/CaseCollisionClass", "Name")); + } + + /// Validates that class-scoped operators are grouped onto a shared operators page. + [Fact] + public void CppEmitterGradualDisclosure_Emit_ClassOperators_CreatesOperatorsPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + Assert.True(factory.HasWriter("testlib/Widget", "operators")); + } + + /// Validates that api.md emits a fallback paragraph when no namespaces are present. + [Fact] + public void CppEmitterGradualDisclosure_Emit_EmptyNamespaces_ApiPageContainsFallbackParagraph() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildEmptyData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + var paragraphs = factory.GetWriter("", "api").Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains("No public API declarations found.", paragraphs); + } + + /// Validates that namespace-level operators are grouped onto a shared namespace operators page. + [Fact] + public void CppEmitterGradualDisclosure_Emit_NamespaceOperators_CreatesOperatorsPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act: BuildRichData() includes a namespace-level operator<< in the testlib namespace + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: namespace operators are written to {namespace}/operators.md + Assert.True(factory.HasWriter("testlib", "operators")); + } } diff --git a/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs index c8964c3..47d5819 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs @@ -13,25 +13,42 @@ namespace ApiMark.Cpp.Tests; /// Unit tests for . public class CppEmitterSingleFileTests { - /// Builds a minimal set of namespace declarations for testing without invoking clang. - private static (CppEmitter emitter, SortedDictionary nsDecls, CppTypeLinkResolver resolver) BuildMinimalData() + /// Builds a representative synthetic API model without invoking clang. + private static (CppEmitter emitter, SortedDictionary nsDecls, CppTypeLinkResolver resolver) BuildData() { var options = new CppGeneratorOptions { LibraryName = "TestLib", PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], }; + var nsDecls = new SortedDictionary(StringComparer.Ordinal); var ns = new CppEmitter.NamespaceDeclarations("testlib", new CppDocComment("A test library.", null, [], null)); - ns.Classes.Add(new CppClass("Widget", [], [], [], [], [], [], false, false, null, + ns.Classes.Add(new CppClass( + "Widget", + [], + [], + [ + new CppFunction("GetValue", "int", [], CppAccessibility.Public, false, false, false, false, false, false, null, new CppDocComment("Gets the current value.", null, [], null)), + ], + [], + [], + [], + false, + false, + null, new CppDocComment("A widget.", null, [], null))); + ns.FreeFunctions.Add(new CppFunction("MakeWidget", "Widget", [], CppAccessibility.Public, false, false, false, false, false, false, null, new CppDocComment("Creates a widget.", null, [], "A widget."))); + ns.Enums.Add(new CppEnum("Color", [new CppEnumValue("Red", new CppDocComment("Red.", null, [], null))], false, null, new CppDocComment("Color options.", null, [], null))); nsDecls["testlib"] = ns; + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal) { { "testlib::Widget", "testlib/Widget" }, + { "testlib::Color", "testlib/Color" }, }); - var emitter = new CppEmitter(options, nsDecls, resolver); - return (emitter, nsDecls, resolver); + + return (new CppEmitter(options, nsDecls, resolver), nsDecls, resolver); } /// Validates that the single-file emitter creates exactly one writer. @@ -40,7 +57,7 @@ public void CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); - var (emitter, nsDecls, resolver) = BuildMinimalData(); + var (emitter, nsDecls, resolver) = BuildData(); // Act new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); @@ -55,13 +72,13 @@ public void CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); - var (emitter, nsDecls, resolver) = BuildMinimalData(); + var (emitter, nsDecls, resolver) = BuildData(); // Act new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); // Assert - Assert.True(factory.HasWriter("", "api"), "Expected api writer to be created"); + Assert.True(factory.HasWriter("", "api")); } /// Validates that the api file contains a library-name heading. @@ -70,14 +87,13 @@ public void CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHead { // Arrange var factory = new InMemoryMarkdownWriterFactory(); - var (emitter, nsDecls, resolver) = BuildMinimalData(); + var (emitter, nsDecls, resolver) = BuildData(); // Act new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); // Assert - var apiWriter = factory.GetWriter("", "api"); - var headings = apiWriter.Operations.OfType().ToList(); + var headings = factory.GetWriter("", "api").Operations.OfType().ToList(); Assert.Contains(headings, h => h.Text.Contains("TestLib", StringComparison.Ordinal)); } @@ -87,14 +103,83 @@ public void CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeadin { // Arrange var factory = new InMemoryMarkdownWriterFactory(); - var (emitter, nsDecls, resolver) = BuildMinimalData(); + var (emitter, nsDecls, resolver) = BuildData(); // Act new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); // Assert - var apiWriter = factory.GetWriter("", "api"); - var headings = apiWriter.Operations.OfType().ToList(); + var headings = factory.GetWriter("", "api").Operations.OfType().ToList(); Assert.Contains(headings, h => h.Text.Contains("testlib", StringComparison.Ordinal)); } + + /// Validates that class declarations are emitted as H3 sections in single-file output. + [Fact] + public void CppEmitterSingleFile_Emit_ClassData_ContainsClassSection() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildData(); + + // Act + new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert + var headings = factory.GetWriter("", "api").Operations.OfType().ToList(); + Assert.Contains(headings, h => h.Level == 3 && h.Text == "Widget"); + } + + /// Validates that free functions are emitted as H3 sections in single-file output. + [Fact] + public void CppEmitterSingleFile_Emit_FreeFunction_ContainsFreeFunctionSection() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildData(); + + // Act + new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert + var headings = factory.GetWriter("", "api").Operations.OfType().ToList(); + Assert.Contains(headings, h => h.Level == 3 && h.Text.StartsWith("MakeWidget(", StringComparison.Ordinal)); + } + + /// Validates that enums are emitted as H3 sections in single-file output. + [Fact] + public void CppEmitterSingleFile_Emit_Enum_ContainsEnumSection() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildData(); + + // Act + new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert + var headings = factory.GetWriter("", "api").Operations.OfType().ToList(); + Assert.Contains(headings, h => h.Level == 3 && h.Text == "Color"); + } + + /// Validates that non-default heading depth offsets are applied consistently. + [Fact] + public void CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildData(); + + // Act + new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit( + factory, + new EmitConfig { Format = OutputFormat.SingleFile, HeadingDepth = 2 }, + new InMemoryContext()); + + // Assert + var headings = factory.GetWriter("", "api").Operations.OfType().ToList(); + Assert.Contains(headings, h => h.Level == 2 && h.Text.Contains("TestLib", StringComparison.Ordinal)); + Assert.Contains(headings, h => h.Level == 3 && h.Text.Contains("testlib", StringComparison.Ordinal)); + Assert.Contains(headings, h => h.Level == 4 && h.Text == "Widget"); + Assert.Contains(headings, h => h.Level == 5 && h.Text.StartsWith("GetValue(", StringComparison.Ordinal)); + } } diff --git a/test/ApiMark.Cpp.Tests/CppEmitterTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterTests.cs index ef9ff97..d9b84df 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterTests.cs @@ -112,7 +112,171 @@ public void CppEmitter_BuildClassDeclaration_FinalClass_AppendsFinalKeyword() // Act var result = CppEmitter.BuildClassDeclaration(cls); + // Assert: the exact declaration string for a final class with no bases + Assert.Equal("class FinalWidget final", result); + } + + /// Validates that appends both final and inheritance list correctly. + [Fact] + public void CppEmitter_BuildClassDeclaration_FinalClassWithBaseTypes_AppendsFinalAndInheritance() + { + // Arrange + var cls = new CppClass( + "FinalWidget", + [new CppBaseType("Base")], + [], + [], + [], + [], + [], + false, + true, + null, + null); + + // Act + var result = CppEmitter.BuildClassDeclaration(cls); + + // Assert: final keyword precedes the inheritance list + Assert.Equal("class FinalWidget final : public Base", result); + } + + /// Validates that colliding member names are merged onto a single combined page keyed by the lowercase name. + [Fact] + public void CppEmitter_WriteCombinedMemberPage_CaseInsensitiveCollision_ProducesSingleCombinedPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var cls = new CppClass( + "CaseCollisionClass", + [], + [], + [ + new CppFunction("Name", "int", [], CppAccessibility.Public, false, false, false, false, false, false, null, null), + ], + [ + new CppField("name", "int", CppAccessibility.Public, false, false, null, null), + ], + [], + [], + false, + false, + null, + null); + + // Act + CppEmitter.WriteCombinedMemberPage( + factory, + "fixtures", + "fixtures", + cls, + "name", + [cls.Members[0], cls.Fields[0]], + resolver); + + // Assert + Assert.True(factory.HasWriter("fixtures/CaseCollisionClass", "name")); + Assert.False(factory.HasWriter("fixtures/CaseCollisionClass", "Name")); + var writer = factory.GetWriter("fixtures/CaseCollisionClass", "name"); + var headings = writer.Operations.OfType().ToList(); + Assert.Contains(headings, h => h.Level == 2 && h.Text.StartsWith("Name", StringComparison.Ordinal)); + Assert.Contains(headings, h => h.Level == 2 && h.Text.StartsWith("name", StringComparison.Ordinal)); + } + + /// Validates that invalid file-name characters are replaced with underscores. + [Fact] + public void CppEmitter_SanitizeFileName_InvalidCharacters_AreReplacedWithUnderscore() + { + // Arrange / Act + var result = CppEmitter.SanitizeFileName("operator/"); + + // Assert + Assert.Equal("operator_", result); + } + + /// Validates that base-class names are appended to the class declaration line. + [Fact] + public void CppEmitter_BuildClassDeclaration_WithBaseTypes_AppendsInheritanceList() + { + // Arrange + var cls = new CppClass( + "Circle", + [new CppBaseType("Shape"), new CppBaseType("Renderable")], + [], + [], + [], + [], + [], + false, + false, + null, + null); + + // Act + var result = CppEmitter.BuildClassDeclaration(cls); + // Assert - Assert.Contains("final", result, StringComparison.Ordinal); + Assert.Equal("class Circle : public Shape, public Renderable", result); + } + + /// + /// Validates that returns a root-relative path + /// when the source file resides under a configured public include root. + /// + [Fact] + public void CppEmitter_GetIncludePath_MatchingRoot_ReturnsRelativePath() + { + // Arrange: emitter whose include root is the fixture include directory + var (emitter, _, _) = BuildMinimalEmitter(); + var root = FixturePaths.GetFixtureIncludeDir(); + var headerFile = Path.Combine(root, "mylib", "widget.h"); + + // Act + var result = emitter.GetIncludePath(headerFile); + + // Assert: path relative to the matching root, forward-slash separated + Assert.Equal("mylib/widget.h", result); + } + + /// + /// Validates that returns the full normalized + /// forward-slash path when the source file does not reside under any configured root. + /// + [Fact] + public void CppEmitter_GetIncludePath_NoMatchingRoot_ReturnsFileName() + { + // Arrange: header path completely outside any configured include root + var (emitter, _, _) = BuildMinimalEmitter(); + var headerFile = Path.Combine(Path.GetTempPath(), "standalone.h"); + + // Act + var result = emitter.GetIncludePath(headerFile); + + // Assert: when no root matches, the result contains the file name + Assert.EndsWith("standalone.h", result, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Validates that emits an + /// "External Types" heading and a table when the external-types set is non-empty. + /// + [Fact] + public void CppEmitter_WriteExternalTypesSection_WithEntries_WritesExternalTypesSection() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + using var writer = factory.CreateMarkdown("", "test"); + var externalTypes = new SortedSet + { + new CppExternalTypeInfo("Logger", "acme"), + }; + + // Act + CppEmitter.WriteExternalTypesSection(writer, externalTypes); + + // Assert: an "External Types" heading must appear in the written output + var headings = factory.GetWriter("", "test").Operations.OfType().ToList(); + Assert.Contains(headings, h => h.Text.Contains("External Types", StringComparison.Ordinal)); } } diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs b/test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs index 16a6d06..811dc2d 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs @@ -5,7 +5,9 @@ namespace ApiMark.Cpp.Tests; /// -/// xUnit class fixture that runs exactly once per +/// xUnit class fixture that runs and +/// +/// exactly once per /// option combination per test run, caching the resulting /// instances for every test in /// to read. @@ -79,12 +81,13 @@ public sealed class CppGeneratorFixture public InMemoryMarkdownWriterFactory PublicSingleFileFactory { get; } /// - /// Initializes the fixture by invoking once for - /// each of the four standard option combinations and storing the resulting factories. + /// Initializes the fixture by invoking and + /// + /// once for each of the five standard option combinations and storing the resulting factories. /// /// /// xUnit constructs this fixture once per test class and shares it across all tests - /// in , so clang is invoked at most four times per run. + /// in , so clang is invoked at most five times per run. /// public CppGeneratorFixture() { diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 65f50ca..fa892e5 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -57,6 +57,22 @@ public void CppGenerator_Constructor_NullOptions_ThrowsArgumentNullException() Assert.Throws(() => new CppGenerator(null!)); } + /// Validates that passing null to throws . + [Fact] + public void CppGenerator_Parse_NullContext_ThrowsArgumentNullException() + { + // Arrange: valid generator with minimal options + var options = new CppGeneratorOptions + { + LibraryName = "TestLibrary", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + }; + var generator = new CppGenerator(options); + + // Act / Assert: null context must be rejected before any file I/O is attempted + Assert.Throws(() => generator.Parse(null!)); + } + /// /// Validates that passing options with an empty /// to the constructor throws . @@ -1306,10 +1322,7 @@ public void CppGenerator_Generate_DefaultParameter_SignatureContainsDefault() s => s.Contains("seed = 0", StringComparison.Ordinal)); } - /// - /// Validates that a Doxygen @note tag is rendered as a blockquote paragraph - /// (prefixed with > **Note:**) on the function's detail page. - /// + /// Validates that a boolean default argument is rendered as false in the function signature. [Fact] public void CppGenerator_Generate_BoolDefaultParameter_SignatureContainsFalse() { diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs index add6e5d..7f9fc17 100644 --- a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -99,4 +99,132 @@ public void CppTypeLinkResolver_Linkify_QualifiedReferenceToAmbiguousType_EmitsC Assert.Contains("[size_type]", result, StringComparison.Ordinal); Assert.Contains("Outer/size_type.md", result, StringComparison.Ordinal); } + + /// Validates that primitive types are returned unchanged and are not tracked as external types. + [Fact] + public void CppTypeLinkResolver_Linkify_PrimitiveType_ReturnsUnchanged() + { + // Arrange + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("int", string.Empty, externalTypes); + + // Assert + Assert.Equal("int", result); + Assert.Empty(externalTypes); + } + + /// Validates that std:: types are returned unchanged and are not tracked as external types. + [Fact] + public void CppTypeLinkResolver_Linkify_StdType_ReturnsUnchanged() + { + // Arrange + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("const std::string &", string.Empty, externalTypes); + + // Assert + Assert.Equal("const std::string &", result); + Assert.Empty(externalTypes); + } + + /// Validates that a null type string is returned unchanged. + [Fact] + public void CppTypeLinkResolver_Linkify_NullInput_ReturnsNull() + { + // Arrange + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify(null!, string.Empty, externalTypes); + + // Assert + Assert.Null(result); + Assert.Empty(externalTypes); + } + + /// Validates that a whitespace-only type string is returned unchanged. + [Fact] + public void CppTypeLinkResolver_Linkify_WhitespaceInput_ReturnsUnchanged() + { + // Arrange + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify(" ", string.Empty, externalTypes); + + // Assert + Assert.Equal(" ", result); + Assert.Empty(externalTypes); + } + + /// Validates that an external namespaced type is tracked for the External Types section. + [Fact] + public void CppTypeLinkResolver_Linkify_ExternalType_AddsToExternalTypesSet() + { + // Arrange + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("acme::Logger *", string.Empty, externalTypes); + + // Assert + Assert.Equal("acme::Logger *", result); + Assert.Single(externalTypes); + Assert.Equal("Logger", externalTypes.First().TypeString); + Assert.Equal("acme", externalTypes.First().Namespace); + } + + /// Validates that leading qualifiers are removed repeatedly before lookup so qualified types still resolve. + [Fact] + public void CppTypeLinkResolver_Linkify_QualifiedType_StripsQualifiersBeforeLookup() + { + // Arrange + var knownTypes = new Dictionary(StringComparer.Ordinal) + { + { "fixtures::SampleClass", "fixtures/SampleClass" }, + }; + var resolver = new CppTypeLinkResolver(knownTypes); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("volatile const fixtures::SampleClass &", "fixtures", externalTypes); + + // Assert + Assert.Contains("[SampleClass]", result, StringComparison.Ordinal); + Assert.Contains("SampleClass.md", result, StringComparison.Ordinal); + Assert.Empty(externalTypes); + } + + /// + /// Validates that when a type name (e.g. "Foo") appears both as the intra-library type + /// being linked and as a prefix of a template argument (e.g. "FooBar"), only the + /// actual type token is wrapped in a link and the template argument is left unchanged. + /// + [Fact] + public void CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplateArg_EmitsLinkWithoutCorruption() + { + // Arrange: Foo is a known intra-library type; FooBar is a template argument not in knownTypes + var knownTypes = new Dictionary(StringComparer.Ordinal) + { + { "ns::Foo", "ns/Foo" }, + }; + var resolver = new CppTypeLinkResolver(knownTypes); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("ns::Foo", "ns", externalTypes); + + // Assert: "Foo" is linked but "FooBar" is not wrapped in a link + Assert.Contains("[Foo](", result, StringComparison.Ordinal); + Assert.DoesNotContain("[FooBar]", result, StringComparison.Ordinal); + Assert.EndsWith("", result, StringComparison.Ordinal); + } } From 7907b4453655ab509d1f8d944a62acb676700034 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 11:00:28 -0400 Subject: [PATCH 05/62] fix: Batch 3 Cpp reviews R4/R5 fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 22 +- .../cpp-emitter-gradual-disclosure.md | 203 +++++++++++++++--- docs/design/api-mark-cpp/cpp-emitter.md | 8 +- docs/design/api-mark-cpp/cpp-generator.md | 2 + .../api-mark-cpp/cpp-type-link-resolver.md | 5 + .../cpp-emitter-gradual-disclosure.yaml | 1 - docs/reqstream/api-mark-cpp/cpp-emitter.yaml | 21 -- .../reqstream/api-mark-cpp/cpp-generator.yaml | 50 +---- docs/verification/api-mark-cpp.md | 35 +++ .../api-mark-cpp/cpp-ast-model.md | 49 ++++- .../cpp-emitter-gradual-disclosure.md | 44 ++-- .../api-mark-cpp/cpp-emitter-single-file.md | 6 + docs/verification/api-mark-cpp/cpp-emitter.md | 23 +- .../api-mark-cpp/cpp-generator.md | 73 ++++++- .../api-mark-cpp/cpp-type-link-resolver.md | 6 + src/ApiMark.Cpp/CppEmitter.cs | 10 + src/ApiMark.Cpp/CppGenerator.cs | 2 +- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 2 +- test/ApiMark.Cpp.Tests/CppAstModelTests.cs | 8 + test/ApiMark.Cpp.Tests/CppEmitterTests.cs | 31 ++- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 35 +++ .../CppTypeLinkResolverTests.cs | 4 +- 22 files changed, 519 insertions(+), 121 deletions(-) diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index 1d37bff..95a7b35 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -27,6 +27,7 @@ flowchart TD CppGenerator --> ClangAstParser ClangAstParser --> clang["clang -ast-dump=json (OTS)"] ClangAstParser --> CppAstModel + CppGenerator --> CppAstModel CppGenerator --> CppEmitter CppEmitter --> CppEmitterGradualDisclosure CppEmitter --> CppEmitterSingleFile @@ -59,6 +60,16 @@ flowchart TD `APIMARK_CLANG_PATH`, PATH, `xcrun clang`, or Windows LLVM discovery; parse failures surface as `InvalidOperationException`. +### IMarkdownWriterFactory (consumed) + +- *Type*: in-process .NET interface from ApiMarkCore. +- *Role*: consumer. +- *Contract*: `CppEmitter` receives a factory instance from the caller and passes + it to the format-specific emitter (`CppEmitterGradualDisclosure` or + `CppEmitterSingleFile`) to create `IMarkdownWriter` instances for each output file. +- *Constraints*: must not be null; `CppEmitter.Emit` throws `ArgumentNullException` + when the factory is null. + ### MSBuild (consumed via ApiMarkTask) - *Type*: build-property interface exposed by the ApiMark MSBuild integration. @@ -84,6 +95,8 @@ flowchart TD - **clang** — external AST parser used through `ClangAstParser`. - **ApiMarkCore** — provides `IApiGenerator`, `IApiEmitter`, `EmitConfig`, `IContext`, `IMarkdownWriterFactory`, and `GlobFileCollector`. +- **CppAstModel** — immutable record types that carry parsed declarations from + `ClangAstParser` through `CppGenerator` to `CppEmitter`. ## Risk Control Measures @@ -101,9 +114,12 @@ N/A - not a safety-classified software item. 3. `CppGenerator` logs system-header diagnostics, rejects public-header parse failures, applies `IncludeDeprecated` while collecting namespaces, and builds a known-type map covering namespaces, nested classes, and type aliases. -4. `CppEmitter.Emit` routes by `OutputFormat`: `SingleFile` uses - `CppEmitterSingleFile`; `GradualDisclosure` uses - `CppEmitterGradualDisclosure`. +4. `CppEmitter.Emit` routes by `OutputFormat`: `GradualDisclosure` uses + `CppEmitterGradualDisclosure`, which produces one `api.md` index and separate + per-namespace, per-type, per-member, per-operator, per-enum, and per-alias pages. + `SingleFile` uses `CppEmitterSingleFile`, which writes the entire API reference + into a single `api.md` file with library-name and namespace headings followed by + type and member sections. 5. During gradual-disclosure emission, regular members receive per-member pages; case-insensitive collisions are combined onto a single lowercase page; operator overloads are detected by name and grouped onto shared class-level or diff --git a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md index c6344c8..f342aeb 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -42,33 +42,182 @@ and dedicated pages for enums, type aliases, and grouped operator overloads. `WriteNestedTypePages`, then calls `WriteFreeFunctionPage`, `WriteEnumPage`, `WriteTypeAliasPage`, `WriteClassOperatorsPage`, and `WriteNamespaceOperatorsPage` as applicable. -- **WriteApiPage** — writes the library entrypoint, namespace table, and path - convention appendix. -- **WriteNamespacePage** — writes the namespace summary page using alphabetical - sub-tables for Types, Enums, Type Aliases, Functions, and Operators; it does not - group declarations by source header. -- **WriteTypePage** — writes a type page with signature block, summary, optional - details/note/example, base-class paragraph, member tables, nested-class table, - and type-alias table. -- **WriteNestedTypePages** — recursively writes nested class pages and class-scoped - type-alias pages beneath the parent class folder. -- **WriteClassMemberTables** — writes Constructors, Methods, Fields, Operators, - Nested Classes, and Type Aliases sections in canonical order. -- **ProcessClassConstructorMember / ProcessClassMethodMember / - ProcessClassFieldMember** — build table rows and create member pages or combined - collision pages. -- **WriteClassSignatureBlock / WriteClassBaseTypesParagraph** — render type-page - signature context and inheritance text. -- **WriteMemberPage / WriteFunctionPage / WriteFieldPage / WriteFunctionContent / - WriteFieldContent** — write per-member detail pages. -- **WriteFreeFunctionPage / WriteFreeFunctionContent** — write namespace-level free - function pages. -- **WriteEnumPage** — writes enum pages and values tables. -- **WriteTypeAliasPage** — writes namespace-level and class-scoped alias pages. -- **WriteClassOperatorsPage** — writes `{nsKey}/{TypeName}/operators.md` for all - class-scoped operators. -- **WriteNamespaceOperatorsPage** — writes `{nsKey}/operators.md` for all - namespace-level operators. + +- **WriteApiPage**: `private void WriteApiPage(IMarkdownWriterFactory factory, + SortedDictionary namespaces)` — writes + the library entrypoint `api.md`. + - *Parameters*: `IMarkdownWriterFactory factory`, sorted `namespaces` dictionary. + - *Preconditions*: `factory` must not be null. + - *Algorithm*: creates a writer at `("", "api")`, emits the library title heading, + optional description paragraph, the namespace summary table (Namespace, Declarations, + Description columns), and the file-naming path-convention appendix table. Emits a + fallback paragraph when `namespaces` is empty. + +- **WriteNamespacePage**: `private static void WriteNamespacePage(IMarkdownWriterFactory factory, + string nsKey, CppEmitter.NamespaceDeclarations nsDecls, CppTypeLinkResolver cppResolver)` — + writes the namespace summary page. + - *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `CppEmitter.NamespaceDeclarations nsDecls`, + `CppTypeLinkResolver cppResolver`. + - *Preconditions*: `factory`, `nsKey`, and `nsDecls` must not be null. + - *Algorithm*: creates a writer at `("", nsKey)`, emits an H1 namespace heading, then + emits Types, Enums, Type Aliases, Functions, and Operators sub-tables in that order — + each sub-table is omitted when the corresponding collection is empty. Accumulates external + type references for the trailing External Types section. + +- **WriteTypePage**: `private void WriteTypePage(CppTypePageWriteContext ctx)` — writes a + type page for a single C++ class or struct. + - *Parameters*: `CppTypePageWriteContext ctx` — bundles factory, namespace key, namespace + display name, class, and resolver. + - *Preconditions*: `ctx` must not be null; `ctx.Class` must not be null. + - *Algorithm*: creates a writer at `(nsKey, className)`, emits an H1 heading with the + display name, the signature block (qualified name comment, optional template declaration, + `#include` directive, optional class declaration line), summary paragraph, extended + details, note, example, base-types paragraph, then calls `WriteClassMemberTables`. + +- **WriteMemberPage**: `private static void WriteMemberPage(IMarkdownWriterFactory factory, + string nsKey, string nsDisplayName, CppClass cls, object member, string fileName, + CppTypeLinkResolver cppResolver)` — writes the detail page for a single class member. + - *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, + `CppClass cls`, `object member` (either `CppFunction` or `CppField`), `string fileName`, + `CppTypeLinkResolver cppResolver`. + - *Preconditions*: `factory`, `cls`, and `member` must not be null; `member` must be a + `CppFunction` or `CppField`. + - *Algorithm*: creates a writer at `("{nsKey}/{cls.Name}", fileName)`, dispatches to + `WriteFunctionPage` or `WriteFieldPage` based on the concrete type of `member`, then + calls `WriteExternalTypesSection`. + +- **WriteEnumPage**: `private void WriteEnumPage(IMarkdownWriterFactory factory, + string nsKey, string nsDisplayName, CppEnum cppEnum)` — writes the detail page for a + single C++ enum. + - *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, + `CppEnum cppEnum`. + - *Preconditions*: `factory` and `cppEnum` must not be null. + - *Algorithm*: creates a writer at `(nsKey, cppEnum.Name)`, emits an H1 heading, the + qualified-name comment and optional `#include` directive in a signature block, summary, + extended details, note, example, and a Values table if the enum has enumerators. + +- **WriteTypeAliasPage**: `private void WriteTypeAliasPage(IMarkdownWriterFactory factory, + string nsKey, string nsDisplayName, CppTypeAlias alias, CppTypeLinkResolver cppResolver)` — + writes a documentation page for a `using` type alias declaration. + - *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, + `CppTypeAlias alias`, `CppTypeLinkResolver cppResolver`. + - *Preconditions*: `factory` and `alias` must not be null. + - *Algorithm*: creates a writer at `(nsKey, alias.Name)`, emits an H1 heading, the + qualified-name comment, optional `#include` directive, and the `using {name} = {underlying}` + declaration in a signature block, then summary, details, note, example, and External Types + section. Calls `cppResolver.Linkify` on the underlying type to track any external + type references. + +- **WriteNestedTypePages**: `private void WriteNestedTypePages(IMarkdownWriterFactory factory, + string parentKey, string parentDisplayName, CppClass cls, CppTypeLinkResolver cppResolver)` — + recursively writes class-scoped type-alias pages and nested class pages. + - *Parameters*: `IMarkdownWriterFactory factory`, `string parentKey`, `string parentDisplayName`, + `CppClass cls`, `CppTypeLinkResolver cppResolver`. + - *Preconditions*: `factory` and `cls` must not be null. + - *Algorithm*: computes the class folder key as `{parentKey}/{cls.Name}`, writes one alias page + per entry in `cls.TypeAliases`, writes one type page per entry in `cls.NestedClasses`, and + recurses into each nested class. + +- **WriteClassMemberTables**: `private void WriteClassMemberTables(IMarkdownWriter writer, + CppTypePageWriteContext ctx, IReadOnlyList operatorMethods, + List ctorRows, List methodRows, List fieldRows, + SortedSet externalTypes)` — emits all member sub-tables onto the type page. + - *Parameters*: writer, ctx, operator methods list, pre-built row lists for constructors, + methods, and fields, and accumulated external types. + - *Algorithm*: emits Constructors, Methods, Fields, Operators, Nested Classes, and Type Aliases + sub-tables in canonical order, each only when the corresponding collection is non-empty. + Calls `WriteClassOperatorsPage` when operator methods are present. Appends the External Types + section at the end. + +- **ProcessClassConstructorMember**: `private static void ProcessClassConstructorMember( + CppTypePageWriteContext ctx, CppFunction ctor, IReadOnlyDictionary> + caseInsensitiveGroups, HashSet writtenKeys, List ctorRows)` — processes a + single constructor, writing its page and appending the table row. + - *Parameters*: ctx, the constructor, the case-insensitive groups map, a set of already-written + page keys, and the row accumulator. + - *Algorithm*: computes the lowercase key and group; if the page has not been written, writes a + single member page or a combined collision page; appends the constructor row with parameter + types in the link text. + +- **ProcessClassMethodMember**: `private static void ProcessClassMethodMember( + CppTypePageWriteContext ctx, CppFunction method, IReadOnlyDictionary> + caseInsensitiveGroups, HashSet writtenKeys, List methodRows, + SortedSet externalTypes)` — processes a single regular method, + writing its page and appending the table row. + - *Parameters*: ctx, the method, case-insensitive groups, written-key tracker, row + accumulator, and external-types set. + - *Algorithm*: same as `ProcessClassConstructorMember` for page writing; linkifies the return + type via the resolver and appends a row with member link, return type, and summary. + +- **ProcessClassFieldMember**: `private static void ProcessClassFieldMember( + CppTypePageWriteContext ctx, CppField field, IReadOnlyDictionary> + caseInsensitiveGroups, HashSet writtenKeys, List fieldRows, + SortedSet externalTypes)` — processes a single visible field, + writing its page and appending the table row. + - *Parameters*: ctx, the field, case-insensitive groups, written-key tracker, row + accumulator, and external-types set. + - *Algorithm*: same as `ProcessClassMethodMember`; linkifies the field type and appends a + row with member link, type, and summary. + +- **WriteClassSignatureBlock**: `private void WriteClassSignatureBlock(IMarkdownWriter writer, + CppClass cls, string qualifiedClassName)` — emits the fenced C++ signature block for a class. + - *Parameters*: `IMarkdownWriter writer`, `CppClass cls`, `string qualifiedClassName`. + - *Algorithm*: calls `GetIncludePath` on the source file, builds a signature string from the + qualified name comment, optional template declaration, `#include` directive, and optional + class declaration line, then calls `writer.WriteSignature`. + +- **WriteClassBaseTypesParagraph**: `private static void WriteClassBaseTypesParagraph( + IMarkdownWriter writer, CppClass cls)` — emits the `**Inherits**: …` paragraph when the class + has base types. + - *Parameters*: `IMarkdownWriter writer`, `CppClass cls`. + - *Algorithm*: returns immediately if `cls.BaseTypes` is empty; otherwise collects simplified + base type names and emits a bold Inherits paragraph. + +- **WriteFunctionPage / WriteFunctionContent**: `private static void WriteFunctionPage(...)` and + `internal static void WriteFunctionContent(IMarkdownWriter writer, CppFunction method, + CppFunctionWriteContext ctx)` — write the H1 heading and body for a class method page. + - *Parameters*: writer, method, context bundling namespace, class name, resolver, folder, + external types, and heading level. + - *Algorithm*: emits a qualified-name comment + signature block, summary, details, note, + example, optional Parameters table, and optional Returns section. + +- **WriteFieldPage / WriteFieldContent**: `private static void WriteFieldPage(...)` and + `internal static void WriteFieldContent(IMarkdownWriter writer, string nsDisplayName, + string className, CppField field)` — write the H1 heading and body for a class field page. + - *Parameters*: writer, namespace display name, class name, field. + - *Algorithm*: emits a qualified-name comment + field-type signature, summary, details, note, + and example. + +- **WriteFreeFunctionPage / WriteFreeFunctionContent**: `private void WriteFreeFunctionPage( + IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, CppFunction fn, + CppTypeLinkResolver cppResolver)` and the corresponding content helper — write the detail page + for a namespace-level free function. + - *Parameters*: factory, namespace key and display name, function, resolver. + - *Algorithm*: creates a writer at `(nsKey, sanitizedFunctionName)`, emits a qualified-name + comment + `#include` + signature block, summary, details, note, example, optional Parameters + table, and optional Returns section. Appends External Types section. + +- **WriteClassOperatorsPage**: `private void WriteClassOperatorsPage(IMarkdownWriterFactory + factory, string nsKey, string nsDisplayName, CppClass cls, IReadOnlyList + operators, CppTypeLinkResolver cppResolver)` — writes the combined operator overloads page for + a class. + - *Parameters*: factory, namespace key and display name, class, list of operator methods, + resolver. + - *Preconditions*: `operators` must not be empty. + - *Algorithm*: creates a writer at `("{nsKey}/{cls.Name}", "operators")`, emits an H1 + heading, optional qualified name + `#include` signature block, a description paragraph, then + an H2 section per operator with its full function content. Appends External Types section. + +- **WriteNamespaceOperatorsPage**: `private void WriteNamespaceOperatorsPage(IMarkdownWriterFactory + factory, string nsKey, string nsDisplayName, IReadOnlyList operators, + CppTypeLinkResolver cppResolver)` — writes the combined operator overloads page for a + namespace. + - *Parameters*: factory, namespace key and display name, list of operator free functions, + resolver. + - *Preconditions*: `operators` must not be empty. + - *Algorithm*: creates a writer at `(nsKey, "operators")`, emits an H1 heading, optional + qualified name + `#include` signature block, a namespace description paragraph, then an H2 + section per operator using `WriteFreeFunctionContent`. Appends External Types section. ### Error Handling diff --git a/docs/design/api-mark-cpp/cpp-emitter.md b/docs/design/api-mark-cpp/cpp-emitter.md index 7a8e197..cdc7483 100644 --- a/docs/design/api-mark-cpp/cpp-emitter.md +++ b/docs/design/api-mark-cpp/cpp-emitter.md @@ -22,9 +22,9 @@ accumulator populated during `CppGenerator.Parse`. - `TypeAliases`: `List` — owned namespace-level aliases. - `FreeFunctions`: `List` — owned free functions. -**CppExternalTypeInfo** (internal record): per-page external-type entry emitted in -an `External Types` section — see _CppTypeLinkResolver Data Model_ for the full -definition. +**CppExternalTypeInfo** (internal record, defined in `CppExternalTypeInfo.cs`): +per-page external-type entry emitted in an `External Types` section — see +_CppTypeLinkResolver Data Model_ for the full definition. **CppTypePageWriteContext** (internal record): bundles per-type-page constants for member page and table generation. @@ -114,6 +114,8 @@ by `WriteFunctionContent`. ### Error Handling - `ArgumentNullException` — thrown by `Emit` when `factory` is null. +- `ArgumentNullException` — thrown by `GetIncludePath` when `sourceFile` is null. +- `ArgumentException` — thrown by `WriteCombinedMemberPage` when `members` contains fewer than two elements. - All writer and factory exceptions are propagated without wrapping. ### External Interfaces diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index f319bad..f2e61f4 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -104,6 +104,8 @@ parse-time deprecated filter, builds the known-type map, and returns a ### Dependencies - **IApiGenerator** — implemented from ApiMarkCore. +- **CppAstModel** — immutable record types received from `ClangAstParser` and + stored while building the known-type map and grouping namespaces. - **GlobFileCollector** — performs gitignore-style header selection. - **ClangAstParser** — parses the selected headers into `CppCompilationResult`. - **CppEmitter** — returned by `Parse` to emit Markdown output. diff --git a/docs/design/api-mark-cpp/cpp-type-link-resolver.md b/docs/design/api-mark-cpp/cpp-type-link-resolver.md index 35ace53..bbb9b28 100644 --- a/docs/design/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -32,6 +32,11 @@ fundamental C++ type names that must always remain plain text. primitives, and `std::` types; resolves exact qualified matches first; falls back to an unambiguous short-name match; when no known type matches and the stripped name has a non-empty non-`std` namespace, records a `CppExternalTypeInfo` entry. + For qualified type strings, the splice uses a position-aware algorithm to prevent + template-argument prefix corruption: derives `startIdx` as the position after the + last `::` in the original string (or 0 for unqualified types), calls + `IndexOf(shortName, startIdx)` to locate the exact token, then reconstructs the + result as `original[..idx] + linked + original[(idx + shortName.Length)..]`. - **FindPageKey** — performs exact qualified lookup first, then a short-name scan that returns null when the short name is ambiguous. - **StripQualifiers** — repeatedly removes leading and trailing `const`, diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml index 2623822..600d724 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml @@ -104,5 +104,4 @@ sections: Namespace operator overloads need the same collision-safe grouping as class operators. tests: - - CppGenerator_Generate_NamespaceFreeOperator_CreatesNamespaceOperatorsPage - CppEmitterGradualDisclosure_Emit_NamespaceOperators_CreatesOperatorsPage diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml index 7e8f0a3..cbeda24 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml @@ -21,8 +21,6 @@ sections: - ApiMarkCpp-CppEmitter-SanitizeFileName - ApiMarkCpp-CppEmitter-BuildClassDeclaration - ApiMarkCpp-CppEmitter-RepresentExternalTypeInfo - - ApiMarkCpp-CppEmitter-RepresentTypePageWriteContext - - ApiMarkCpp-CppEmitter-RepresentFunctionWriteContext tests: - CppEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles - CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile @@ -78,22 +76,3 @@ sections: WriteExternalTypesSection. tests: - CppEmitter_WriteExternalTypesSection_WithEntries_WritesExternalTypesSection - - id: ApiMarkCpp-CppEmitter-RepresentTypePageWriteContext - title: >- - CppEmitter shall produce consistent type page output for a given class, - regardless of how many times Emit is called with the same inputs. - justification: | - Consistent output guarantees that the emitter is purely functional with - respect to its inputs, so that documentation generation is reproducible and - deterministic. - tests: - - CppEmitterGradualDisclosure_Emit_MethodMember_CreatesMemberDetailPage - - id: ApiMarkCpp-CppEmitter-RepresentFunctionWriteContext - title: >- - CppEmitter shall produce consistent function page output for a given - function, regardless of how many times Emit is called with the same inputs. - justification: | - Consistent output guarantees that function page generation is reproducible - and deterministic across both gradual-disclosure and single-file formats. - tests: - - CppEmitterGradualDisclosure_Emit_ClassOperators_CreatesOperatorsPage diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index b2a8dbc..491946c 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -153,10 +153,6 @@ sections: - CppGenerator_OutputFiles_FollowNamingConvention - CppGenerator_Generate_ValidHeaders_CreatesNamespacePage - CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass - - CppEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles - - CppEmitter_SanitizeFileName_RegularName_IsUnchanged - - CppEmitterGradualDisclosure_Emit_MinimalData_CreatesNamespacePage - - CppEmitterGradualDisclosure_Emit_MinimalData_CreatesTypePage - id: ApiMarkCpp-CppGenerator-ApiMdListsAllNamespacesWithTypeCount title: CppGenerator shall list all namespaces in api.md with a declaration count column. @@ -167,8 +163,6 @@ sections: opening it first. tests: - CppGenerator_Generate_ApiMd_ListsNamespacesWithTypeCount - - CppEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage - - CppEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibraryNameHeading - id: ApiMarkCpp-CppGenerator-GroupOperatorOverloadsOnSinglePage title: >- CppGenerator shall group all operator overloads for a class onto a single @@ -201,9 +195,6 @@ sections: tests: - CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature - CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature - - CppEmitter_BuildClassDeclaration_NonFinalNoBase_ReturnsJustClassName - - CppEmitter_BuildClassDeclaration_FinalClass_AppendsFinalKeyword - - CppEmitter_BuildClassDeclaration_WithBaseTypes_AppendsInheritanceList - id: ApiMarkCpp-CppGenerator-EmitIntraDocLinksInTableCells title: CppGenerator shall emit Markdown links in type and parameter type table cells when the referenced type is documented in the same library. @@ -234,24 +225,18 @@ sections: - CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles - CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader - CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_ExcludesHeader - - id: ApiMarkCpp-CppGenerator-SupportAbsoluteAndRelativeHeaderPatterns + - id: ApiMarkCpp-CppGenerator-SupportAbsoluteHeaderPatterns title: >- - CppGenerator shall support both absolute and relative ApiHeaderPatterns - glob patterns: relative patterns shall be resolved from - CppGeneratorOptions.WorkingDirectory (defaulting to the process CWD when - WorkingDirectory is null or empty), and absolute patterns shall be - forwarded directly to GlobFileCollector. + CppGenerator shall support absolute ApiHeaderPatterns glob patterns by + forwarding them directly to GlobFileCollector without WorkingDirectory + resolution. justification: | - WorkingDirectory-relative patterns allow users to write patterns that reflect - their project directory layout directly (e.g. `include/**` when WorkingDirectory - is the project root) without needing to hard-code absolute paths. Absolute - patterns allow headers on other drives or outside all configured roots to be - included directly. WorkingDirectory defaults to the process CWD so existing - callers that omit the property continue to work correctly. + Absolute patterns allow headers on other drives or outside all configured + roots to be included directly. Forwarding them as-is to GlobFileCollector + ensures the full absolute path is preserved and the pattern can match + headers outside the WorkingDirectory tree. tests: - - GlobFileCollector_Collect_AbsolutePattern_FindsFiles - - CppGenerator_Generate_ApiHeaderPatterns_CwdRelativePattern_OnlyMatchingFilesDocumented - - CppGenerator_Generate_ApiHeaderPatterns_CwdRelativeExclusionPattern_ExcludesMatchingFiles + - CppGenerator_Generate_ApiHeaderPatterns_AbsolutePattern_DocumentsMatchingFile - id: ApiMarkCpp-CppGenerator-WorkingDirectory title: >- CppGenerator shall use CppGeneratorOptions.WorkingDirectory as the base @@ -263,17 +248,7 @@ sections: independently of the process CWD, making builds reproducible across machines. tests: - CppGenerator_Generate_ApiHeaderPatterns_CwdRelativePattern_OnlyMatchingFilesDocumented - - id: ApiMarkCpp-CppGenerator-EmitExternalTypesSection - title: CppGenerator shall emit an "External Types" section at the bottom - of each generated page that references at least one non-std external - type. - justification: | - Readers using an external library type documented on a page need to know - which namespace or header to include. Collecting all external type - references per page and listing them alphabetically provides this context - without cluttering individual type cells with fully-qualified names. - tests: - - CppTypeLinkResolver_Linkify_ExternalType_AddsToExternalTypesSet + - CppGenerator_Generate_ApiHeaderPatterns_CwdRelativeExclusionPattern_ExcludesMatchingFiles - id: ApiMarkCpp-CppGenerator-ShowDeletedFunctionsWithDeletedNotation title: >- CppGenerator shall document explicitly deleted functions and operators @@ -316,11 +291,6 @@ sections: tests: - CppGenerator_Generate_SingleFileOutput_WritesSingleApiMarkdown - CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock - - CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile - - CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter - - CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly - - CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHeading - - CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeading - id: ApiMarkCpp-CppGenerator-RejectNullContext title: CppGenerator.Parse shall reject a null IContext argument. justification: | diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index b771099..35fc0c7 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -38,6 +38,10 @@ configuration beyond a standard clang installation is required. pages, and `{namespace}/{TypeName}/{MemberName}.md` member detail pages. - When the single-file format is specified, all documentation is written to a single `api.md` file using a flat H1/H2/H3/H4 heading hierarchy. +- `api.md` lists all namespaces in a table with a Declarations count column so AI agents can + calibrate exploration depth for each namespace. +- Doxygen `@code`/`@endcode` blocks are rendered as fenced `cpp` code blocks on both + gradual-disclosure member pages and single-file output. ## Test Scenarios @@ -192,3 +196,34 @@ individual alias pages. This scenario is tested by `--format single-file` option is specified, all documentation is written to a single `api.md` file using a flat heading hierarchy rather than producing separate namespace and type pages. This scenario is tested by `CppGenerator_Generate_SingleFileFormat_WritesToSingleFile`. + +**api.md lists all namespaces with type count**: Verifies that `api.md` contains a namespace +table where every documented namespace appears with a Declarations count column so that AI agents +have a complete navigation map in a single read. This scenario is tested by +`CppGenerator_Generate_ApiMd_ListsNamespacesWithTypeCount`. + +**Class operator overloads grouped on single operators page**: Verifies that all operator +overloads for a class are combined onto a single `operators.md` page at +`{namespace}/{TypeName}/operators` and that the owning type page links to it. This scenario is +tested by `CppGenerator_Generate_ClassWithOperators_CreatesOperatorsPage`, +`CppGenerator_Generate_ClassWithOperators_OperatorsPageContainsOperatorEntry`, and +`CppGenerator_Generate_ClassWithOperators_TypePageLinksToOperatorsPage`. + +**Intra-library return type emits Markdown link in table cell**: Verifies that a method whose +return type is a documented intra-library type produces a Markdown hyperlink in the Returns +column of the Methods table. This scenario is tested by +`CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell`. + +**Gitignore-style ApiHeaderPatterns restrict documented headers**: Verifies that the generator +correctly applies include, exclude, and re-include patterns to restrict the documented API surface. +This scenario is tested by `CppGenerator_Generate_NoApiHeaderPatterns_DocumentsAllHeaders`, +`CppGenerator_Generate_ApiHeaderPatterns_IncludePattern_OnlyMatchingFilesDocumented`, +`CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles`, +`CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader`, and +`CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_ExcludesHeader`. + +**Code example blocks rendered as fenced code**: Verifies that Doxygen `@code`/`@endcode` blocks +on documented methods are rendered as fenced `cpp` code blocks on both gradual-disclosure member +pages and single-file output. This scenario is tested by +`CppGenerator_Generate_MethodWithCodeExample_EmitsCodeBlockOnMemberPage` and +`CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock`. diff --git a/docs/verification/api-mark-cpp/cpp-ast-model.md b/docs/verification/api-mark-cpp/cpp-ast-model.md index 88f7927..b00ba0e 100644 --- a/docs/verification/api-mark-cpp/cpp-ast-model.md +++ b/docs/verification/api-mark-cpp/cpp-ast-model.md @@ -52,5 +52,50 @@ property. Tested by `CppFunction_Construction_SetsCoreProperties`. `IsFinal = false` reports that value correctly. Tested by `CppClass_Construction_SetsCoreProperties`. -**CppField.IsDeprecated flag**: Verifies that `CppField` stores the `IsDeprecated` flag -supplied at construction time. Tested by `CppField_Construction_SetsCoreProperties`. +**CppField.IsDeprecated flag**: Verifies that a `CppField` constructed with `IsDeprecated = false` +reports that value correctly from the `IsDeprecated` property. Tested by +`CppField_Construction_SetsCoreProperties`. + +**CppDocComment constructor stores Params and Returns**: Verifies that constructing `CppDocComment` +with an empty params list and a returns string correctly stores both in `Params` and `Returns`. +Tested by `CppDocComment_Construction_SetsSummaryAndDetails`. + +**CppFunction boolean flags default correctly**: Verifies that a `CppFunction` constructed with all +boolean flags set to `false` (IsStatic, IsVirtual, IsVariadic, IsDeprecated) reports each correctly. +Tested by `CppFunction_Construction_SetsCoreProperties`. + +**CppClass.IsDeprecated flag**: Verifies that a `CppClass` constructed with `IsDeprecated = false` +reports that value correctly from the `IsDeprecated` property. Tested by +`CppClass_Construction_SetsCoreProperties`. + +**CppSourceLocation stores file and line**: Verifies that `CppSourceLocation_Construction_SetsFileAndLine` +correctly stores the file path in `File` and the line number in `Line`. + +**CppParamDoc stores name and description**: Verifies that `CppParamDoc_Construction_SetsNameAndDescription` +correctly stores the parameter name and description string. + +**CppBaseType stores name**: Verifies that `CppBaseType_Construction_SetsName` correctly stores the +base class name. + +**CppTemplateParam stores name**: Verifies that `CppTemplateParam_Construction_SetsName` correctly +stores the template parameter name. + +**CppEnumValue stores name and doc**: Verifies that `CppEnumValue_Construction_SetsNameAndDoc` +correctly stores the enumerator name and its optional doc comment. + +**CppParameter stores name and type**: Verifies that `CppParameter_Construction_SetsNameAndTypeName` +correctly stores the parameter name and type string. + +**CppEnum stores name and values**: Verifies that `CppEnum_Construction_SetsNameAndValues` correctly +stores the enum name and the list of declared enumerator values. + +**CppTypeAlias stores name and underlying type**: Verifies that +`CppTypeAlias_Construction_SetsNameAndUnderlyingType` correctly stores the alias name and its +underlying type string. + +**CppNamespaceDecl stores qualified name**: Verifies that +`CppNamespaceDecl_Construction_SetsQualifiedName` correctly stores the fully-qualified namespace name. + +**CppCompilationResult stores namespaces and errors**: Verifies that +`CppCompilationResult_Construction_SetsNamespacesAndErrors` correctly stores the namespace list and +the error string collection. diff --git a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md index dec7136..95423da 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -21,23 +21,43 @@ Tests run with the standard xUnit.net test runner. ### Test Scenarios -**Class operators page**: Tested by `CppEmitterGradualDisclosure_Emit_ClassOperators_CreatesOperatorsPage`. - -**Enum page**: Tested by `CppEmitterGradualDisclosure_Emit_Enum_CreatesEnumPage`. - -**Type alias page**: Tested by `CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage`. - -**Nested class page**: Tested by `CppEmitterGradualDisclosure_Emit_NestedClass_CreatesNestedClassPage`. - -**Case-insensitive collision**: Tested by +**Class operators page**: Verifies that a class containing operator overloads produces a shared +`operators.md` page at `{namespace}/{TypeName}/operators` so all operator overloads are navigable +from a single deterministic location. Tested by +`CppEmitterGradualDisclosure_Emit_ClassOperators_CreatesOperatorsPage`. + +**Enum page**: Verifies that a namespace-owned enum declaration produces its own detail page at +`{namespace}/{EnumName}` with an H1 heading matching the enum name and a values table listing all +enumerators. Tested by `CppEmitterGradualDisclosure_Emit_Enum_CreatesEnumPage`. + +**Type alias page**: Verifies that a namespace-level `using` type alias declaration produces its +own page at `{namespace}/{AliasName}` containing the `using` declaration in a signature block. +Tested by `CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage`. + +**Nested class page**: Verifies that a class nested inside a public outer class produces its own +page at `{namespace}/{OuterType}/{NestedType}` so readers can navigate to it directly from the +outer class page. Tested by +`CppEmitterGradualDisclosure_Emit_NestedClass_CreatesNestedClassPage`. + +**Case-insensitive collision**: Verifies that two members whose names differ only in case are +combined onto a single lowercase-keyed page rather than producing two separately-named pages that +would collide on case-insensitive file systems. Tested by `CppEmitterGradualDisclosure_Emit_CaseInsensitiveCollision_CreatesCombinedPage`. -**Empty namespace fallback**: Tested by +**Empty namespace fallback**: Verifies that when the namespace collection is empty the emitter +still produces an `api.md` page containing a fallback paragraph so the output is never completely +empty. Tested by `CppEmitterGradualDisclosure_Emit_EmptyNamespaces_ApiPageContainsFallbackParagraph`. -**Member detail page**: Tested by `CppEmitterGradualDisclosure_Emit_MethodMember_CreatesMemberDetailPage`. +**Member detail page**: Verifies that a visible class method produces its own detail page at +`{namespace}/{TypeName}/{MemberName}` with an H1 heading and a signature block, confirming that +the member page emission path is wired correctly. Tested by +`CppEmitterGradualDisclosure_Emit_MethodMember_CreatesMemberDetailPage`. -**Free-function page**: Tested by `CppEmitterGradualDisclosure_Emit_FreeFunction_CreatesFreeFunctionPage`. +**Free-function page**: Verifies that a namespace-level free function produces its own page at +`{namespace}/{FunctionName}` with a signature block, confirming that the free-function emission +path is separate from the class-member path. Tested by +`CppEmitterGradualDisclosure_Emit_FreeFunction_CreatesFreeFunctionPage`. **Api index page creation**: Verifies that the emitter creates the api index page. Tested by `CppEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage`. diff --git a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md index 8ac7239..1b4642d 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md @@ -38,3 +38,9 @@ is configured. This scenario is tested by exactly one Markdown writer keyed as `api` at the output root. Tested by `CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter` and `CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly`. + +**Library name heading**: Verifies that `CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHeading` +confirms the generated `api.md` contains the configured library name as a top-level heading. + +**Namespace heading**: Verifies that `CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeading` +confirms the generated `api.md` contains a namespace section heading for each documented namespace. diff --git a/docs/verification/api-mark-cpp/cpp-emitter.md b/docs/verification/api-mark-cpp/cpp-emitter.md index d2577dc..7629dd3 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter.md +++ b/docs/verification/api-mark-cpp/cpp-emitter.md @@ -19,6 +19,8 @@ Tests run with the standard xUnit.net test runner. - `SanitizeFileName` preserves valid names and replaces invalid characters with underscores. - `BuildClassDeclaration` renders non-final, final, and inherited class declarations correctly. - `WriteCombinedMemberPage` emits one shared page for case-insensitive member collisions. +- `GetIncludePath` returns a root-relative path when the source file is under a configured public root; returns the full normalized path when no root matches. +- `WriteExternalTypesSection` writes an H2 "External Types" heading and a table row per entry when the set is non-empty; writes nothing when the set is empty. ### Test Scenarios @@ -42,13 +44,17 @@ underscores instead of causing output-path failures. This scenario is tested by file-name characters are returned unchanged. Tested by `CppEmitter_SanitizeFileName_RegularName_IsUnchanged`. +**BuildClassDeclaration with final class**: Verifies that the `final` keyword is appended to the +class declaration when `CppClass.IsFinal` is true. Tested by +`CppEmitter_BuildClassDeclaration_FinalClass_AppendsFinalKeyword`. + **BuildClassDeclaration with base types**: Verifies that base class names are appended to the class declaration line in the form `class X : public Base`. Tested by `CppEmitter_BuildClassDeclaration_WithBaseTypes_AppendsInheritanceList`. -**BuildClassDeclaration with final and base types**: Verifies that both the `final` keyword -and the inheritance list are correctly combined in the declaration string. Tested by -`CppEmitter_BuildClassDeclaration_FinalClassWithBaseTypes_AppendsFinalAndInheritance`. +**BuildClassDeclaration non-final no-base**: Verifies that a non-final class with no base types +produces a declaration string containing only the class keyword and name. Tested by +`CppEmitter_BuildClassDeclaration_NonFinalNoBase_ReturnsJustClassName`. **WriteCombinedMemberPage for case-insensitive collisions**: Verifies that members whose names differ only in case are merged onto a single lowercase-keyed page. Tested by @@ -62,6 +68,11 @@ path. Tested by `CppEmitter_GetIncludePath_MatchingRoot_ReturnsRelativePath`. under any configured root produces the full normalized path. Tested by `CppEmitter_GetIncludePath_NoMatchingRoot_ReturnsFileName`. -**WriteExternalTypesSection emits heading with entries**: Verifies that a non-empty -external-types set causes `WriteExternalTypesSection` to write an "External Types" heading. -Tested by `CppEmitter_WriteExternalTypesSection_WithEntries_WritesExternalTypesSection`. +**WriteExternalTypesSection emits H2 heading with table rows**: Verifies that a non-empty +external-types set causes `WriteExternalTypesSection` to write an H2 "External Types" heading +and a table row for each entry containing the type name and namespace. Tested by +`CppEmitter_WriteExternalTypesSection_WithEntries_WritesExternalTypesSection`. + +**WriteExternalTypesSection produces no output for empty set**: Verifies that an empty +external-types set causes `WriteExternalTypesSection` to write no headings and no tables. +Tested by `CppEmitter_WriteExternalTypesSection_EmptySet_WritesNothing`. diff --git a/docs/verification/api-mark-cpp/cpp-generator.md b/docs/verification/api-mark-cpp/cpp-generator.md index 099e74a..e53d420 100644 --- a/docs/verification/api-mark-cpp/cpp-generator.md +++ b/docs/verification/api-mark-cpp/cpp-generator.md @@ -27,6 +27,11 @@ Tests require the fixture headers and a system clang installation accessible on tracked for External Types sections. - Type pages show fully qualified names, deleted notation, finality, and direct inheritance. - Public type aliases, enums, nested classes, and single-file output are documented correctly. +- Gitignore-style `ApiHeaderPatterns` (include, exclude, re-include, last-match-wins) are + applied correctly to restrict the documented header set. +- Doxygen `@code`/`@endcode` blocks are rendered as fenced `cpp` code blocks on both + gradual-disclosure member pages and single-file output. +- `api.md` lists all namespaces with a declaration count column for AI navigation scope. ### Test Scenarios @@ -64,8 +69,72 @@ output. Tested by `CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleCl **Intra-library type references emit Markdown links**: Verifies that a known intra-library type referenced in a member signature produces a Markdown hyperlink in the generated table -cell. Tested by `CppGenerator_Generate_TypeLinkInMemberSignature_EmitsMarkdownLink`. +cell. Tested by `CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell`. **Deleted notation on type pages**: Verifies that a method declared `= delete` is annotated as deleted on its generated page. Tested by -`CppGenerator_Generate_DeletedMember_ShowsDeletedNotation`. +`CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix` and +`CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix`. + +**Class operator overloads produce a shared operators page**: Verifies that all operator +overloads for a class are grouped onto a single `operators.md` page rather than individual +colliding pages. Tested by `CppGenerator_Generate_ClassWithOperators_CreatesOperatorsPage`. + +**Operators page contains each operator entry**: Verifies that every declared operator +overload appears as a heading on the shared operators page so readers can locate a specific +overload. Tested by `CppGenerator_Generate_ClassWithOperators_OperatorsPageContainsOperatorEntry`. + +**Type page links to operators page**: Verifies that the owning class's type page contains a +table cell linking to the shared `operators.md` page so readers can navigate to it. Tested by +`CppGenerator_Generate_ClassWithOperators_TypePageLinksToOperatorsPage`. + +**Final class emits final keyword in signature**: Verifies that the type page for a `final` +class includes the `final` keyword in its signature block. Tested by +`CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature`. + +**api.md lists namespaces with declaration count**: Verifies that `api.md` contains a namespace +table where each row includes the namespace name and a declarations count column. Tested by +`CppGenerator_Generate_ApiMd_ListsNamespacesWithTypeCount`. + +**No ApiHeaderPatterns documents all headers**: Verifies that when `ApiHeaderPatterns` is +empty, all recognized header files under the include roots are documented. Tested by +`CppGenerator_Generate_NoApiHeaderPatterns_DocumentsAllHeaders`. + +**Include pattern restricts to matching files**: Verifies that a positive `ApiHeaderPatterns` +entry restricts documentation to headers matching that pattern. Tested by +`CppGenerator_Generate_ApiHeaderPatterns_IncludePattern_OnlyMatchingFilesDocumented`. + +**Exclude pattern removes matching files**: Verifies that a `!`-prefixed exclusion pattern +removes the named header from the documented set while leaving other headers present. Tested by +`CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles`. + +**Re-include after exclude uses last-match-wins semantics**: Verifies that a header first +excluded and then re-included by a later positive pattern is documented, confirming gitignore-style +last-match-wins semantics. Tested by +`CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader`. + +**Exclude without re-include permanently excludes header**: Verifies that an exclusion pattern +without a subsequent re-include permanently removes the header from the documented set. Tested by +`CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_ExcludesHeader`. + +**Single-file output writes one api.md**: Verifies that selecting `OutputFormat.SingleFile` +produces exactly one writer keyed `api` containing all namespace and type documentation in a +flat heading hierarchy. Tested by `CppGenerator_Generate_SingleFileOutput_WritesSingleApiMarkdown`. + +**Code example block on member page**: Verifies that a Doxygen `@code`/`@endcode` block on a +method produces a fenced `cpp` code block on the gradual-disclosure member page. Tested by +`CppGenerator_Generate_MethodWithCodeExample_EmitsCodeBlockOnMemberPage`. + +**Code example block in single-file output**: Verifies that a Doxygen `@code`/`@endcode` block +on a method produces a fenced `cpp` code block in single-file output. Tested by +`CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock`. + +**Intra-library return type emits Markdown link in table cell**: Verifies that a method whose +return type is a known intra-library type produces a Markdown hyperlink in the Returns column of +the Methods table. Tested by +`CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell`. + +**Case-collision members do not create separate cased page**: Verifies that when a +case-insensitive collision exists, no separate page is created for the upper-case member name — +only the combined lowercase page exists. Tested by +`CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage`. diff --git a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md index 4de6602..cf63158 100644 --- a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md @@ -24,6 +24,12 @@ the standard xUnit.net test runner. No clang installation is needed. name) produces plain text and does not produce a link. - A fully-qualified reference to one of two ambiguously-named types resolves correctly via the exact-match path and produces the correct Markdown link. +- Primitive and `std::` types return the original string unchanged and are not added to + the external-types set. +- A non-std, non-library type whose stripped name carries a non-empty namespace is added + to the caller's external-types set, and the original string is returned unchanged. +- Null input returns null; whitespace-only input returns unchanged — neither causes an + exception or external-type tracking. ### Test Scenarios diff --git a/src/ApiMark.Cpp/CppEmitter.cs b/src/ApiMark.Cpp/CppEmitter.cs index 6b14805..7117e84 100644 --- a/src/ApiMark.Cpp/CppEmitter.cs +++ b/src/ApiMark.Cpp/CppEmitter.cs @@ -159,6 +159,7 @@ internal static string SanitizeFileName(string name) /// internal string GetIncludePath(string sourceFile) { + ArgumentNullException.ThrowIfNull(sourceFile); var normalized = Path.GetFullPath(sourceFile); // Select the longest matching root so the most specific prefix wins @@ -518,6 +519,10 @@ internal static string SimplifyTypeName(string typeName) /// Must contain at least two elements. /// /// Type link resolver used to linkify parameter type cells. + /// + /// Thrown when contains fewer than two elements. + /// A combined page requires at least two members; call individual member page writers for single members. + /// internal static void WriteCombinedMemberPage( IMarkdownWriterFactory factory, string nsKey, @@ -527,6 +532,11 @@ internal static void WriteCombinedMemberPage( IReadOnlyList members, CppTypeLinkResolver cppResolver) { + if (members.Count < 2) + { + throw new ArgumentException("WriteCombinedMemberPage requires at least two members.", nameof(members)); + } + var combinedCurrentFolder = $"{nsKey}/{cls.Name}"; using var writer = factory.CreateMarkdown(combinedCurrentFolder, lowerKey); diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 00293b7..0ae49e4 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -64,7 +64,7 @@ public CppGenerator(CppGeneratorOptions options) /// Enumerate candidate header files under each entry. /// Run clang with -ast-dump=json on all candidate headers via . /// Log any clang diagnostic errors from system headers via the context output channel. - /// Walk the parsed namespaces, applying the ownership filter and deprecated filter. + /// Walk the parsed namespaces, applying the deprecated filter. /// /// The caller must subsequently invoke to write output. /// diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index 07c5512..0379fdb 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -91,7 +91,7 @@ public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) /// /// Mutable set that accumulates non-std external type references found during /// resolution. The caller creates this set per output file and emits the "External - /// Types" section after all table rows have been written. + /// Types" section after all table rows have been written. Must not be null. /// /// /// A Markdown string: either a link of the form [Name](relative/path.md), diff --git a/test/ApiMark.Cpp.Tests/CppAstModelTests.cs b/test/ApiMark.Cpp.Tests/CppAstModelTests.cs index 8ed5b14..308cf41 100644 --- a/test/ApiMark.Cpp.Tests/CppAstModelTests.cs +++ b/test/ApiMark.Cpp.Tests/CppAstModelTests.cs @@ -43,6 +43,8 @@ public void CppDocComment_Construction_SetsSummaryAndDetails() // Assert Assert.Equal("A brief summary.", doc.Summary); Assert.Equal("Detailed explanation.", doc.Details); + Assert.Empty(doc.Params); + Assert.Equal("Returns a value.", doc.Returns); } /// Validates that two identical instances are equal (record equality). @@ -144,6 +146,7 @@ public void CppField_Construction_SetsCoreProperties() Assert.Equal("int", field.TypeName); Assert.Equal(CppAccessibility.Private, field.Accessibility); Assert.False(field.IsStatic); + Assert.False(field.IsDeprecated); } /// Validates that stores core properties correctly. @@ -161,6 +164,10 @@ public void CppFunction_Construction_SetsCoreProperties() Assert.Equal(CppAccessibility.Public, fn.Accessibility); Assert.False(fn.IsConstructor); Assert.False(fn.IsDeleted); + Assert.False(fn.IsStatic); + Assert.False(fn.IsVirtual); + Assert.False(fn.IsVariadic); + Assert.False(fn.IsDeprecated); } /// Validates that stores core properties correctly. @@ -174,6 +181,7 @@ public void CppClass_Construction_SetsCoreProperties() Assert.Equal("Widget", cls.Name); Assert.Empty(cls.BaseTypes); Assert.False(cls.IsFinal); + Assert.False(cls.IsDeprecated); } /// Validates that stores Name and Values correctly. diff --git a/test/ApiMark.Cpp.Tests/CppEmitterTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterTests.cs index d9b84df..57ffe4e 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterTests.cs @@ -275,8 +275,37 @@ public void CppEmitter_WriteExternalTypesSection_WithEntries_WritesExternalTypes // Act CppEmitter.WriteExternalTypesSection(writer, externalTypes); - // Assert: an "External Types" heading must appear in the written output + // Assert: an "External Types" H2 heading must appear in the written output var headings = factory.GetWriter("", "test").Operations.OfType().ToList(); Assert.Contains(headings, h => h.Text.Contains("External Types", StringComparison.Ordinal)); + Assert.Contains(headings, h => h.Level == 2 && h.Text.Contains("External Types", StringComparison.Ordinal)); + + // Assert: the table must include a row for Logger in the acme namespace + var tables = factory.GetWriter("", "test").Operations.OfType().ToList(); + var allCells = tables.SelectMany(t => t.Rows).SelectMany(r => r).ToList(); + Assert.Contains(allCells, c => c.Contains("Logger", StringComparison.Ordinal)); + Assert.Contains(allCells, c => c.Contains("acme", StringComparison.Ordinal)); + } + + /// + /// Validates that writes no output + /// when the external-types set is empty. + /// + [Fact] + public void CppEmitter_WriteExternalTypesSection_EmptySet_WritesNothing() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + using var writer = factory.CreateMarkdown("", "empty"); + var externalTypes = new SortedSet(); + + // Act + CppEmitter.WriteExternalTypesSection(writer, externalTypes); + + // Assert: no operations were written to the writer — empty set must produce no output + var headings = factory.GetWriter("", "empty").Operations.OfType().ToList(); + Assert.Empty(headings); + var tables = factory.GetWriter("", "empty").Operations.OfType().ToList(); + Assert.Empty(tables); } } diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index fa892e5..8cc252c 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1560,4 +1560,39 @@ public void CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock() string.Equals(cb.Language, "cpp", StringComparison.Ordinal) && cb.Code.Contains("GetGreeting", StringComparison.Ordinal)); } + + /// + /// Validates that an absolute entry + /// selects the file it names via its absolute path, confirming that absolute patterns are + /// forwarded directly to the file collector without WorkingDirectory resolution. + /// + [Fact] + public void CppGenerator_Generate_ApiHeaderPatterns_AbsolutePattern_DocumentsMatchingFile() + { + // Arrange: build an absolute path to SampleClass.h and pass it as the only pattern + var absoluteHeaderPath = Path.GetFullPath( + Path.Combine(FixturePaths.GetFixtureNamespaceDir(), "SampleClass.h")); + + var options = new CppGeneratorOptions + { + LibraryName = "Fixtures", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + ApiHeaderPatterns = [absoluteHeaderPath], + }; + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new CppGenerator(options); + + // Act + generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: SampleClass page must exist because the absolute pattern matched its header + Assert.True( + factory.Writers.ContainsKey("fixtures/SampleClass"), + "Expected SampleClass page when its absolute header path was used as a pattern"); + + // Assert: SampleStatus page must not exist because SampleEnum.h was not matched + Assert.False( + factory.Writers.ContainsKey("fixtures/SampleStatus"), + "Expected SampleStatus to be absent when not matched by the absolute header pattern"); + } } diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs index 7f9fc17..40ad2ee 100644 --- a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -46,8 +46,10 @@ public void CppTypeLinkResolver_Linkify_UnambiguousShortName_EmitsLink() // Act var result = resolver.Linkify("Bar", "ns", externalTypes); - // Assert: unambiguous short name produces a link + // Assert: unambiguous short name produces a link with correct path; no external type tracked Assert.Contains("[Bar]", result, StringComparison.Ordinal); + Assert.Contains("Bar.md", result, StringComparison.Ordinal); + Assert.Empty(externalTypes); } /// From d3e1bd4abc7b3bda0d60d9c0d097d1b8e6bcf645 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 11:47:41 -0400 Subject: [PATCH 06/62] fix: Batch 3 Cpp reviews R5/R6 fixes Group A: TLR fixes - Remove null/whitespace tests from LinkifyPrimitiveType requirement (A1) - Add ArgumentNullException.ThrowIfNull(externalTypes) guard in Linkify (A2) - Add currentFolder ??= string.Empty null-coalescing semantics (A3) - Add 8th acceptance criterion to TLR verification doc (A4) Group B: CppGenerator external types integration test - Create ExternalTypeFixture.h with external::ns::Logger* return type (B1) - Add CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection test (B1) - Add external types criterion and scenario to api-mark-cpp.md (B2) Group C: Verification doc prose format fixes - Rewrite 10 scenario descriptions in cpp-ast-model.md (C1) - Fix 2 scenario descriptions in cpp-emitter-single-file.md (C2) - Add inheritance criterion and scenario to api-mark-cpp.md (C3) Group D: Test organisation - Move CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType from CppGeneratorTests.cs to CppTypeLinkResolverTests.cs (D1) - Replace with CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection (D1) Group E: CppEmitter low findings - Fix GetIncludePath XML doc: 'Must not be null or empty' -> 'Must not be null' (E1) - Add CppEmitter_GetIncludePath_NullSourceFile_ThrowsArgumentNullException test (E2) - Add CppEmitter_WriteCombinedMemberPage_TooFewMembers_ThrowsArgumentException test (E2) - Update cpp-emitter.md verification doc with new criteria and scenarios (E2/E3) - Add FinalClassWithBaseTypes test to BuildClassDeclaration requirement yaml (E3) Group F: CppAstModel low findings - Add Assert.False(cppEnum.IsDeprecated) to CppEnum construction test (F1) - Add Assert.False(alias.IsDeprecated) to CppTypeAlias construction test (F1) - Add Assert.Null(fn.Location/fn.Doc) to CppFunction construction test (F2) - Add Assert.Null(cls.Location/cls.Doc) to CppClass construction test (F2) - Add Assert.Null(field.Doc) to CppField construction test (F2) Group G: GD low findings - Reorder PathConventionRows table to match code array order (G1) - Add WriteClassSignatureBlock null-Location early-return note (G2) - Add WriteTypePage early-exit condition for empty classes (G2) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 9 ++-- .../cpp-emitter-gradual-disclosure.md | 11 ++-- docs/design/api-mark-cpp/cpp-emitter.md | 10 ++++ docs/reqstream/api-mark-cpp.yaml | 5 +- docs/reqstream/api-mark-cpp/cpp-emitter.yaml | 1 + .../api-mark-cpp/cpp-type-link-resolver.yaml | 3 +- docs/verification/api-mark-cpp.md | 16 +++++- .../api-mark-cpp/cpp-ast-model.md | 50 +++++++++++-------- .../cpp-emitter-gradual-disclosure.md | 1 + .../api-mark-cpp/cpp-emitter-single-file.md | 10 ++-- docs/verification/api-mark-cpp/cpp-emitter.md | 17 +++++++ .../api-mark-cpp/cpp-generator.md | 4 -- .../api-mark-cpp/cpp-type-link-resolver.md | 3 ++ src/ApiMark.Cpp/CppEmitter.cs | 2 +- src/ApiMark.Cpp/CppGenerator.cs | 6 ++- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 8 ++- .../include/fixtures/ExternalTypeFixture.h | 21 ++++++++ test/ApiMark.Cpp.Tests/CppAstModelTests.cs | 7 +++ test/ApiMark.Cpp.Tests/CppEmitterTests.cs | 37 ++++++++++++++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 32 ++++++------ .../CppTypeLinkResolverTests.cs | 24 +++++++++ 21 files changed, 217 insertions(+), 60 deletions(-) create mode 100644 test/ApiMark.Cpp.Fixtures/include/fixtures/ExternalTypeFixture.h diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index 95a7b35..d182e76 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -17,7 +17,7 @@ through the ApiMarkCore interfaces. The system contains the following units: `CppCompilationResult`. - **CppEmitter** — format dispatcher and shared-helper hub. - **CppEmitterGradualDisclosure** — writes namespace, type, member, operator, - enum, and alias pages. + enum, alias, nested-type, and class-scoped alias pages. - **CppEmitterSingleFile** — writes all documentation into one `api.md` file. - **CppTypeLinkResolver** — resolves intra-library type links and tracks external types. @@ -33,8 +33,10 @@ flowchart TD CppEmitter --> CppEmitterSingleFile CppEmitter --> CppTypeLinkResolver CppEmitter --> IMarkdownWriterFactory + CppGenerator --> CppTypeLinkResolver CppEmitterGradualDisclosure --> CppAstModel CppEmitterSingleFile --> CppAstModel + CppEmitterGradualDisclosure --> CppTypeLinkResolver ``` ## External Interfaces @@ -116,7 +118,8 @@ N/A - not a safety-classified software item. known-type map covering namespaces, nested classes, and type aliases. 4. `CppEmitter.Emit` routes by `OutputFormat`: `GradualDisclosure` uses `CppEmitterGradualDisclosure`, which produces one `api.md` index and separate - per-namespace, per-type, per-member, per-operator, per-enum, and per-alias pages. + per-namespace, per-type, per-member, per-operator, per-enum, per-alias, + per-nested-type, and per-class-scoped-alias pages. `SingleFile` uses `CppEmitterSingleFile`, which writes the entire API reference into a single `api.md` file with library-name and namespace headings followed by type and member sections. @@ -143,4 +146,4 @@ N/A - not a safety-classified software item. `InvalidOperationException`. - Output scope: operator overloads are grouped, macros are out of scope, and primary template declarations are documented while partial specializations and - concepts remain outside v1 scope. + concepts are not in scope. diff --git a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md index f342aeb..0193dd4 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -20,10 +20,10 @@ and dedicated pages for enums, type aliases, and grouped operator overloads. | Type | `{Namespace}/{TypeName}.md` | | Member | `{Namespace}/{TypeName}/{MemberName}.md` | | Nested type | `{Namespace}/{OuterType}/{NestedType}.md` | +| Class-scoped type alias | `{Namespace}/{TypeName}/{AliasName}.md` | | Free function | `{Namespace}/{FunctionName}.md` | | Enum | `{Namespace}/{EnumName}.md` | | Type alias | `{Namespace}/{AliasName}.md` | -| Class-scoped type alias | `{Namespace}/{TypeName}/{AliasName}.md` | | Operators (class) | `{Namespace}/{TypeName}/operators.md` | | Operators (namespace) | `{Namespace}/operators.md` | @@ -72,7 +72,10 @@ and dedicated pages for enums, type aliases, and grouped operator overloads. - *Algorithm*: creates a writer at `(nsKey, className)`, emits an H1 heading with the display name, the signature block (qualified name comment, optional template declaration, `#include` directive, optional class declaration line), summary paragraph, extended - details, note, example, base-types paragraph, then calls `WriteClassMemberTables`. + details, note, example, base-types paragraph, then calls `WriteClassMemberTables`. If + the class has no visible members, no nested classes, and no type aliases, returns after + emitting the heading, signature block, summary, details, note, example, and base-types + paragraph; otherwise proceeds to `WriteClassMemberTables`. - **WriteMemberPage**: `private static void WriteMemberPage(IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, CppClass cls, object member, string fileName, @@ -164,7 +167,9 @@ and dedicated pages for enums, type aliases, and grouped operator overloads. - *Parameters*: `IMarkdownWriter writer`, `CppClass cls`, `string qualifiedClassName`. - *Algorithm*: calls `GetIncludePath` on the source file, builds a signature string from the qualified name comment, optional template declaration, `#include` directive, and optional - class declaration line, then calls `writer.WriteSignature`. + class declaration line, then calls `writer.WriteSignature`. Returns immediately without + emitting any signature content when `cls.Location` is null or `cls.Location.File` is null + or empty. - **WriteClassBaseTypesParagraph**: `private static void WriteClassBaseTypesParagraph( IMarkdownWriter writer, CppClass cls)` — emits the `**Inherits**: …` paragraph when the class diff --git a/docs/design/api-mark-cpp/cpp-emitter.md b/docs/design/api-mark-cpp/cpp-emitter.md index cdc7483..d798c5a 100644 --- a/docs/design/api-mark-cpp/cpp-emitter.md +++ b/docs/design/api-mark-cpp/cpp-emitter.md @@ -129,6 +129,16 @@ by `WriteFunctionContent`. - _Contract_: `Emit` writes the full Markdown output using the supplied factory. - _Constraints_: callers must supply a non-null factory and a fully parsed emitter. +#### IMarkdownWriterFactory (consumed) + +- _Type_: in-process .NET interface from ApiMarkCore. +- _Role_: consumer. +- _Contract_: received from the caller in `Emit`, validated (null throws + `ArgumentNullException`), then forwarded to the format-specific sub-emitter + (`CppEmitterGradualDisclosure` or `CppEmitterSingleFile`) to create per-page + `IMarkdownWriter` instances. +- _Constraints_: must not be null. + ### Dependencies - **IMarkdownWriterFactory** — creates per-page writers. diff --git a/docs/reqstream/api-mark-cpp.yaml b/docs/reqstream/api-mark-cpp.yaml index e7cfb37..70579b4 100644 --- a/docs/reqstream/api-mark-cpp.yaml +++ b/docs/reqstream/api-mark-cpp.yaml @@ -25,8 +25,9 @@ sections: - ApiMarkCpp-CppGenerator-ShowDirectInheritanceInTypeSignature - ApiMarkCpp-CppGenerator-EmitIntraDocLinksInTableCells - ApiMarkCpp-CppGenerator-ApplyGitignoreStylePatternSelectionToHeaders - - ApiMarkCpp-CppGenerator-SupportAbsoluteAndRelativeHeaderPatterns - - ApiMarkCpp-CppGenerator-EmitExternalTypesSection + - ApiMarkCpp-CppGenerator-SupportAbsoluteHeaderPatterns + - ApiMarkCpp-CppGenerator-WorkingDirectory + - ApiMarkCpp-CppGenerator-RejectNullContext - ApiMarkCpp-CppGenerator-ShowDeletedFunctionsWithDeletedNotation - ApiMarkCpp-CppGenerator-DocumentTypeAliases - ApiMarkCpp-CppGenerator-EmitSingleFileOutput diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml index cbeda24..1e01972 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml @@ -62,6 +62,7 @@ sections: - CppEmitter_BuildClassDeclaration_NonFinalNoBase_ReturnsJustClassName - CppEmitter_BuildClassDeclaration_FinalClass_AppendsFinalKeyword - CppEmitter_BuildClassDeclaration_WithBaseTypes_AppendsInheritanceList + - CppEmitter_BuildClassDeclaration_FinalClassWithBaseTypes_AppendsFinalAndInheritance - id: ApiMarkCpp-CppEmitter-RepresentExternalTypeInfo title: >- CppEmitter shall emit an External Types section listing referenced diff --git a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml index 81a397c..fbfb7b7 100644 --- a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml @@ -57,8 +57,7 @@ sections: Primitive types are not documentation pages and must remain plain text. tests: - CppTypeLinkResolver_Linkify_PrimitiveType_ReturnsUnchanged - - CppTypeLinkResolver_Linkify_NullInput_ReturnsNull - - CppTypeLinkResolver_Linkify_WhitespaceInput_ReturnsUnchanged + - CppTypeLinkResolver_Linkify_StdType_ReturnsUnchanged - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyStdType title: CppTypeLinkResolver.Linkify shall leave std:: types unchanged. justification: | diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index 35fc0c7..728ccc2 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -31,17 +31,22 @@ configuration beyond a standard clang installation is required. require combining members onto one shared page. - Explicitly deleted functions and operators are documented with a `= delete` suffix in their signatures. +- Type pages for derived classes include the direct base class name in the class signature block. - `using` type aliases receive their own pages at `{namespace}/{aliasName}.md` and are listed in the namespace summary under a "Type Aliases" section. - Output files follow the naming convention: `api.md` entrypoint, `{namespace}.md` namespace summaries, `{namespace}/{TypeName}.md` type pages, `{namespace}/{AliasName}.md` type alias - pages, and `{namespace}/{TypeName}/{MemberName}.md` member detail pages. + pages, `{namespace}/{TypeName}/{MemberName}.md` member detail pages, + `{namespace}/{TypeName}/{NestedType}.md` nested-type pages, and + `{namespace}/{TypeName}/{AliasName}.md` class-scoped type alias pages. - When the single-file format is specified, all documentation is written to a single `api.md` file using a flat H1/H2/H3/H4 heading hierarchy. - `api.md` lists all namespaces in a table with a Declarations count column so AI agents can calibrate exploration depth for each namespace. - Doxygen `@code`/`@endcode` blocks are rendered as fenced `cpp` code blocks on both gradual-disclosure member pages and single-file output. +- When output contains references to external (non-library, non-std) types, the generated + documentation includes an External Types section listing those types alphabetically. ## Test Scenarios @@ -60,6 +65,10 @@ in the fixture headers receives its own type page, confirming the ownership filt generation path are correct. This scenario is tested by `CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass`. +**Inheritance class includes base type in signature**: Verifies that a class with a public base class +has the base class name included in the generated type page class signature. This scenario is tested by +`CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature`. + **Deprecated class is excluded when IncludeDeprecated is false**: Verifies that a class marked `[[deprecated]]` does not receive a type page when the IncludeDeprecated option is false, confirming the default exclude behavior. This scenario is tested by @@ -227,3 +236,8 @@ on documented methods are rendered as fenced `cpp` code blocks on both gradual-d pages and single-file output. This scenario is tested by `CppGenerator_Generate_MethodWithCodeExample_EmitsCodeBlockOnMemberPage` and `CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock`. + +**External types section in generated output**: Verifies that when a documented type references an +external (non-std, non-library) type, the generated page includes an `External Types` section with +the external type listed. This scenario is tested by +`CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection`. diff --git a/docs/verification/api-mark-cpp/cpp-ast-model.md b/docs/verification/api-mark-cpp/cpp-ast-model.md index b00ba0e..b5cc724 100644 --- a/docs/verification/api-mark-cpp/cpp-ast-model.md +++ b/docs/verification/api-mark-cpp/cpp-ast-model.md @@ -68,34 +68,40 @@ Tested by `CppFunction_Construction_SetsCoreProperties`. reports that value correctly from the `IsDeprecated` property. Tested by `CppClass_Construction_SetsCoreProperties`. -**CppSourceLocation stores file and line**: Verifies that `CppSourceLocation_Construction_SetsFileAndLine` -correctly stores the file path in `File` and the line number in `Line`. +**CppSourceLocation stores file and line**: Verifies that `CppSourceLocation` stores the file path +in `File` and the line number in `Line` correctly. Tested by +`CppSourceLocation_Construction_SetsFileAndLine`. -**CppParamDoc stores name and description**: Verifies that `CppParamDoc_Construction_SetsNameAndDescription` -correctly stores the parameter name and description string. +**CppParamDoc stores name and description**: Verifies that `CppParamDoc` stores the parameter name +in `Name` and the description string in `Description` correctly. Tested by +`CppParamDoc_Construction_SetsNameAndDescription`. -**CppBaseType stores name**: Verifies that `CppBaseType_Construction_SetsName` correctly stores the -base class name. +**CppBaseType stores name**: Verifies that `CppBaseType` stores the base class name in `Name` +correctly. Tested by `CppBaseType_Construction_SetsName`. -**CppTemplateParam stores name**: Verifies that `CppTemplateParam_Construction_SetsName` correctly -stores the template parameter name. +**CppTemplateParam stores name**: Verifies that `CppTemplateParam` stores the template parameter +name in `Name` correctly. Tested by `CppTemplateParam_Construction_SetsName`. -**CppEnumValue stores name and doc**: Verifies that `CppEnumValue_Construction_SetsNameAndDoc` -correctly stores the enumerator name and its optional doc comment. +**CppEnumValue stores name and doc**: Verifies that `CppEnumValue` stores the enumerator name in +`Name` and its optional doc comment in `Doc` correctly. Tested by +`CppEnumValue_Construction_SetsNameAndDoc`. -**CppParameter stores name and type**: Verifies that `CppParameter_Construction_SetsNameAndTypeName` -correctly stores the parameter name and type string. +**CppParameter stores name and type**: Verifies that `CppParameter` stores the parameter name in +`Name` and the type string in `TypeName` correctly. Tested by +`CppParameter_Construction_SetsNameAndTypeName`. -**CppEnum stores name and values**: Verifies that `CppEnum_Construction_SetsNameAndValues` correctly -stores the enum name and the list of declared enumerator values. +**CppEnum stores name and values**: Verifies that `CppEnum` stores the enum name in `Name` and the +list of declared enumerator values in `Values` correctly. Tested by +`CppEnum_Construction_SetsNameAndValues`. -**CppTypeAlias stores name and underlying type**: Verifies that -`CppTypeAlias_Construction_SetsNameAndUnderlyingType` correctly stores the alias name and its -underlying type string. +**CppTypeAlias stores name and underlying type**: Verifies that `CppTypeAlias` stores the alias name +in `Name` and its underlying type string in `UnderlyingTypeName` correctly. Tested by +`CppTypeAlias_Construction_SetsNameAndUnderlyingType`. -**CppNamespaceDecl stores qualified name**: Verifies that -`CppNamespaceDecl_Construction_SetsQualifiedName` correctly stores the fully-qualified namespace name. +**CppNamespaceDecl stores qualified name**: Verifies that `CppNamespaceDecl` stores the +fully-qualified namespace name in `QualifiedName` correctly. Tested by +`CppNamespaceDecl_Construction_SetsQualifiedName`. -**CppCompilationResult stores namespaces and errors**: Verifies that -`CppCompilationResult_Construction_SetsNamespacesAndErrors` correctly stores the namespace list and -the error string collection. +**CppCompilationResult stores namespaces and errors**: Verifies that `CppCompilationResult` stores +the namespace list in `Namespaces` and the error string collection in `Errors` correctly. Tested by +`CppCompilationResult_Construction_SetsNamespacesAndErrors`. diff --git a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md index 95423da..74c3e0f 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -18,6 +18,7 @@ Tests run with the standard xUnit.net test runner. - The emitter creates enum pages, type-alias pages, nested-type pages, and operator pages. - Case-insensitive collisions are combined onto one page. - Empty namespace collections still produce an `api.md` fallback page. +- The api index page heading contains the configured library name. ### Test Scenarios diff --git a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md index 1b4642d..3eee86d 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md @@ -39,8 +39,10 @@ exactly one Markdown writer keyed as `api` at the output root. Tested by `CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter` and `CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly`. -**Library name heading**: Verifies that `CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHeading` -confirms the generated `api.md` contains the configured library name as a top-level heading. +**Library name heading**: Verifies that the generated `api.md` contains the configured library name +as a top-level heading. Tested by +`CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsLibraryNameHeading`. -**Namespace heading**: Verifies that `CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeading` -confirms the generated `api.md` contains a namespace section heading for each documented namespace. +**Namespace heading**: Verifies that the generated `api.md` contains a namespace section heading for +each documented namespace. Tested by +`CppEmitterSingleFile_Emit_MinimalData_ApiFileContainsNamespaceHeading`. diff --git a/docs/verification/api-mark-cpp/cpp-emitter.md b/docs/verification/api-mark-cpp/cpp-emitter.md index 7629dd3..6ea7dfc 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter.md +++ b/docs/verification/api-mark-cpp/cpp-emitter.md @@ -19,7 +19,9 @@ Tests run with the standard xUnit.net test runner. - `SanitizeFileName` preserves valid names and replaces invalid characters with underscores. - `BuildClassDeclaration` renders non-final, final, and inherited class declarations correctly. - `WriteCombinedMemberPage` emits one shared page for case-insensitive member collisions. +- `WriteCombinedMemberPage` rejects a `members` list containing fewer than two elements with `ArgumentException`. - `GetIncludePath` returns a root-relative path when the source file is under a configured public root; returns the full normalized path when no root matches. +- `GetIncludePath` rejects a null `sourceFile` with `ArgumentNullException`. - `WriteExternalTypesSection` writes an H2 "External Types" heading and a table row per entry when the set is non-empty; writes nothing when the set is empty. ### Test Scenarios @@ -64,10 +66,20 @@ names differ only in case are merged onto a single lowercase-keyed page. Tested residing under a configured public include root produces a root-relative, forward-slash path. Tested by `CppEmitter_GetIncludePath_MatchingRoot_ReturnsRelativePath`. +**GetIncludePath rejects null source file**: Verifies that passing null to `GetIncludePath` +throws `ArgumentNullException` immediately, so callers that supply an invalid path receive a +clear error before any path processing is attempted. Tested by +`CppEmitter_GetIncludePath_NullSourceFile_ThrowsArgumentNullException`. + **GetIncludePath returns full path when no root matches**: Verifies that a source file not under any configured root produces the full normalized path. Tested by `CppEmitter_GetIncludePath_NoMatchingRoot_ReturnsFileName`. +**WriteCombinedMemberPage rejects too few members**: Verifies that a `members` list +containing only one element throws `ArgumentException`, enforcing the contract that a +combined page requires at least two members. Tested by +`CppEmitter_WriteCombinedMemberPage_TooFewMembers_ThrowsArgumentException`. + **WriteExternalTypesSection emits H2 heading with table rows**: Verifies that a non-empty external-types set causes `WriteExternalTypesSection` to write an H2 "External Types" heading and a table row for each entry containing the type name and namespace. Tested by @@ -76,3 +88,8 @@ and a table row for each entry containing the type name and namespace. Tested by **WriteExternalTypesSection produces no output for empty set**: Verifies that an empty external-types set causes `WriteExternalTypesSection` to write no headings and no tables. Tested by `CppEmitter_WriteExternalTypesSection_EmptySet_WritesNothing`. + +**BuildClassDeclaration final class with base types**: Verifies that a class marked both +`final` and with a public base type produces a declaration string containing both the `final` +keyword and the inheritance list in the correct order. Tested by +`CppEmitter_BuildClassDeclaration_FinalClassWithBaseTypes_AppendsFinalAndInheritance`. diff --git a/docs/verification/api-mark-cpp/cpp-generator.md b/docs/verification/api-mark-cpp/cpp-generator.md index e53d420..3700c80 100644 --- a/docs/verification/api-mark-cpp/cpp-generator.md +++ b/docs/verification/api-mark-cpp/cpp-generator.md @@ -67,10 +67,6 @@ whose source file falls under a configured `PublicIncludeRoot` appear in the gen output. Tested by `CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass` and `CppGenerator_Generate_ApiHeaderPatterns_TransitiveInclude_ExcludesNonSelectedSymbols`. -**Intra-library type references emit Markdown links**: Verifies that a known intra-library -type referenced in a member signature produces a Markdown hyperlink in the generated table -cell. Tested by `CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell`. - **Deleted notation on type pages**: Verifies that a method declared `= delete` is annotated as deleted on its generated page. Tested by `CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix` and diff --git a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md index cf63158..b4285cb 100644 --- a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md @@ -30,6 +30,9 @@ the standard xUnit.net test runner. No clang installation is needed. to the caller's external-types set, and the original string is returned unchanged. - Null input returns null; whitespace-only input returns unchanged — neither causes an exception or external-type tracking. +- When a type name appears in a template argument position sharing a prefix with the + resolved type (e.g., `Foo` where `Foo` is a known type), only the actual type + token is wrapped in a Markdown link and the template argument is left unchanged. ### Test Scenarios diff --git a/src/ApiMark.Cpp/CppEmitter.cs b/src/ApiMark.Cpp/CppEmitter.cs index 7117e84..5868ec7 100644 --- a/src/ApiMark.Cpp/CppEmitter.cs +++ b/src/ApiMark.Cpp/CppEmitter.cs @@ -151,7 +151,7 @@ internal static string SanitizeFileName(string name) /// overlap (e.g. both include/ and include/mylib/ are configured). /// /// - /// The absolute or relative source file path. Must not be null or empty. + /// The absolute or relative source file path. Must not be null. /// /// /// A forward-slash-separated relative path suitable for a #include directive diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 0ae49e4..8b423de 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -65,6 +65,8 @@ public CppGenerator(CppGeneratorOptions options) /// Run clang with -ast-dump=json on all candidate headers via . /// Log any clang diagnostic errors from system headers via the context output channel. /// Walk the parsed namespaces, applying the deprecated filter. + /// Build the intra-library type map by flattening owned types from each namespace. + /// Construct and return a wrapping the resolved namespace declarations. /// /// The caller must subsequently invoke to write output. /// @@ -251,8 +253,8 @@ internal static void CheckForErrors(CppCompilationResult result, IReadOnlyList /// Maps a parsed into the generator's internal - /// accumulator, applying the configured - /// visibility and deprecated filters. + /// accumulator, applying the + /// deprecated filter. /// /// The namespace declaration to process. /// Dictionary that accumulates declarations grouped by namespace key. diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index 0379fdb..bf09ec6 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -86,7 +86,8 @@ public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) /// /// The folder path of the Markdown file that will contain the link, relative to the /// documentation output root (e.g. "fixtures/SampleClass"). - /// Used to compute relative path hrefs. Pass an empty string for root-level files. + /// Used to compute relative path hrefs. Pass an empty string or + /// for root-level files; a value is treated as an empty string. /// /// /// Mutable set that accumulates non-std external type references found during @@ -107,6 +108,11 @@ public string Linkify( return cppTypeString; } + ArgumentNullException.ThrowIfNull(externalTypes); + + // Treat null currentFolder as an empty string (root-level file) + currentFolder ??= string.Empty; + // Strip qualifiers to isolate the base type name for lookup var stripped = StripQualifiers(cppTypeString); if (string.IsNullOrEmpty(stripped)) diff --git a/test/ApiMark.Cpp.Fixtures/include/fixtures/ExternalTypeFixture.h b/test/ApiMark.Cpp.Fixtures/include/fixtures/ExternalTypeFixture.h new file mode 100644 index 0000000..4427558 --- /dev/null +++ b/test/ApiMark.Cpp.Fixtures/include/fixtures/ExternalTypeFixture.h @@ -0,0 +1,21 @@ +#pragma once + +// Forward-declare an external (non-library, non-std) type so clang accepts the +// method signature without requiring a separate include. +namespace external { +namespace ns { +class Logger; +} // namespace ns +} // namespace external + +namespace fixtures { + +/// @brief A fixture class for testing external type references in generated documentation. +class ExternalTypeFixture { +public: + /// @brief Returns a pointer to an external logger. + /// @return A pointer to the external Logger instance. + external::ns::Logger* GetLogger(); +}; + +} // namespace fixtures diff --git a/test/ApiMark.Cpp.Tests/CppAstModelTests.cs b/test/ApiMark.Cpp.Tests/CppAstModelTests.cs index 308cf41..5c399f5 100644 --- a/test/ApiMark.Cpp.Tests/CppAstModelTests.cs +++ b/test/ApiMark.Cpp.Tests/CppAstModelTests.cs @@ -147,6 +147,7 @@ public void CppField_Construction_SetsCoreProperties() Assert.Equal(CppAccessibility.Private, field.Accessibility); Assert.False(field.IsStatic); Assert.False(field.IsDeprecated); + Assert.Null(field.Doc); } /// Validates that stores core properties correctly. @@ -168,6 +169,8 @@ public void CppFunction_Construction_SetsCoreProperties() Assert.False(fn.IsVirtual); Assert.False(fn.IsVariadic); Assert.False(fn.IsDeprecated); + Assert.Null(fn.Location); + Assert.Null(fn.Doc); } /// Validates that stores core properties correctly. @@ -182,6 +185,8 @@ public void CppClass_Construction_SetsCoreProperties() Assert.Empty(cls.BaseTypes); Assert.False(cls.IsFinal); Assert.False(cls.IsDeprecated); + Assert.Null(cls.Location); + Assert.Null(cls.Doc); } /// Validates that stores Name and Values correctly. @@ -197,6 +202,7 @@ public void CppEnum_Construction_SetsNameAndValues() // Assert Assert.Equal("Color", cppEnum.Name); Assert.Equal(2, cppEnum.Values.Count); + Assert.False(cppEnum.IsDeprecated); } /// Validates that stores Name and UnderlyingTypeName correctly. @@ -209,6 +215,7 @@ public void CppTypeAlias_Construction_SetsNameAndUnderlyingType() // Assert Assert.Equal("handle_t", alias.Name); Assert.Equal("void*", alias.UnderlyingTypeName); + Assert.False(alias.IsDeprecated); } /// Validates that stores QualifiedName correctly. diff --git a/test/ApiMark.Cpp.Tests/CppEmitterTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterTests.cs index 57ffe4e..2906f9d 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterTests.cs @@ -257,6 +257,43 @@ public void CppEmitter_GetIncludePath_NoMatchingRoot_ReturnsFileName() Assert.EndsWith("standalone.h", result, StringComparison.OrdinalIgnoreCase); } + /// + /// Validates that passing null to throws + /// before any path processing is attempted. + /// + [Fact] + public void CppEmitter_GetIncludePath_NullSourceFile_ThrowsArgumentNullException() + { + // Arrange + var (emitter, _, _) = BuildMinimalEmitter(); + + // Act / Assert: null source file must be rejected immediately + Assert.Throws(() => emitter.GetIncludePath(null!)); + } + + /// + /// Validates that passing a list with fewer than two elements to + /// throws , + /// because a combined page by definition requires at least two members. + /// + [Fact] + public void CppEmitter_WriteCombinedMemberPage_TooFewMembers_ThrowsArgumentException() + { + // Arrange: a factory, resolver, class, and a list with only one member + var factory = new InMemoryMarkdownWriterFactory(); + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var cls = new CppClass("TestClass", [], [], [], [], [], [], false, false, null, null); + var singleMember = new CppFunction( + "GetCount", "int", [], CppAccessibility.Public, + false, false, false, false, false, false, null, null); + + // Act / Assert: one-element list must be rejected — combined page requires ≥ 2 + Assert.Throws(() => + CppEmitter.WriteCombinedMemberPage( + factory, "ns", "ns", cls, "getcount", + [singleMember], resolver)); + } + /// /// Validates that emits an /// "External Types" heading and a table when the external-types set is non-empty. diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 8cc252c..90a8e49 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1133,26 +1133,28 @@ public void CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInRetu } /// - /// Validates that emits plain text and records - /// the type in the external set when the type is not in the known-types dictionary. + /// Validates that when an ExternalTypeFixture class has a method whose return type + /// belongs to a non-library, non-std namespace, the generated type page contains an + /// External Types section listing those types. /// [Fact] - public void CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType() + public void CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection() { - // Arrange: a resolver with no known types, simulating a fully external reference - var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); - var externalTypes = new SortedSet(); - - // Act: resolve a type that belongs to a non-std namespace not in the known-types map - var result = resolver.Linkify("acme::Logger *", string.Empty, externalTypes); + // Arrange: ExternalTypeFixture.h declares a class whose GetLogger method returns + // external::ns::Logger* — a type outside both the fixtures namespace and std:: + var factory = _fixture.PublicFactory; - // Assert: the original type string is returned as-is (no link to an unknown page) - Assert.Equal("acme::Logger *", result); + // Assert: type page for ExternalTypeFixture must exist + Assert.True( + factory.Writers.TryGetValue("fixtures/ExternalTypeFixture", out var writer), + "Expected type page for ExternalTypeFixture"); - // Assert: the type is tracked in the external types set with the correct namespace - Assert.Single(externalTypes); - Assert.Equal("Logger", externalTypes.First().TypeString); - Assert.Equal("acme", externalTypes.First().Namespace); + // Assert: the type page must contain an External Types section because GetLogger + // returns a non-library, non-std type that is tracked by CppTypeLinkResolver + var headings = writer.Operations.OfType().ToList(); + Assert.Contains( + headings, + h => h.Level == 2 && h.Text.Contains("External Types", StringComparison.Ordinal)); } /// diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs index 40ad2ee..73aade0 100644 --- a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -229,4 +229,28 @@ public void CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplat Assert.DoesNotContain("[FooBar]", result, StringComparison.Ordinal); Assert.EndsWith("", result, StringComparison.Ordinal); } + + /// + /// Validates that when the type is not in the known-types dictionary, + /// returns the original string unchanged and records + /// the type in the external-types set so the caller can emit an External Types section. + /// + [Fact] + public void CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType() + { + // Arrange: a resolver with no known types, simulating a fully external reference + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var externalTypes = new SortedSet(); + + // Act: resolve a type that belongs to a non-std namespace not in the known-types map + var result = resolver.Linkify("acme::Logger *", string.Empty, externalTypes); + + // Assert: the original type string is returned as-is (no link to an unknown page) + Assert.Equal("acme::Logger *", result); + + // Assert: the type is tracked in the external types set with the correct namespace + Assert.Single(externalTypes); + Assert.Equal("Logger", externalTypes.First().TypeString); + Assert.Equal("acme", externalTypes.First().Namespace); + } } From 03a034979c4700ea882f0c63d838a870f5ecf93b Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 12:11:12 -0400 Subject: [PATCH 07/62] fix: Batch 3 Cpp reviews R6/R7 fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 8 ++++++- .../api-mark-cpp/cpp-emitter-single-file.md | 8 +++---- docs/design/api-mark-cpp/cpp-generator.md | 3 +++ .../api-mark-cpp/cpp-type-link-resolver.md | 7 ++++-- .../api-mark-cpp/cpp-emitter-single-file.yaml | 2 +- .../api-mark-cpp/cpp-type-link-resolver.yaml | 1 - docs/verification/api-mark-cpp.md | 11 +++++++-- .../api-mark-cpp/cpp-emitter-single-file.md | 4 ++-- .../api-mark-cpp/cpp-generator.md | 23 ++++++++++++++---- .../api-mark-cpp/cpp-type-link-resolver.md | 2 ++ src/ApiMark.Cpp/CppTypeLinkResolver.cs | 6 +++-- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 2 +- .../CppTypeLinkResolverTests.cs | 24 ------------------- 13 files changed, 56 insertions(+), 45 deletions(-) diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index d182e76..a3a8f93 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -34,9 +34,13 @@ flowchart TD CppEmitter --> CppTypeLinkResolver CppEmitter --> IMarkdownWriterFactory CppGenerator --> CppTypeLinkResolver + CppEmitter --> CppAstModel CppEmitterGradualDisclosure --> CppAstModel CppEmitterSingleFile --> CppAstModel CppEmitterGradualDisclosure --> CppTypeLinkResolver + CppEmitterSingleFile --> CppTypeLinkResolver + CppEmitterGradualDisclosure --> IMarkdownWriterFactory + CppEmitterSingleFile --> IMarkdownWriterFactory ``` ## External Interfaces @@ -123,7 +127,9 @@ N/A - not a safety-classified software item. `SingleFile` uses `CppEmitterSingleFile`, which writes the entire API reference into a single `api.md` file with library-name and namespace headings followed by type and member sections. -5. During gradual-disclosure emission, regular members receive per-member pages; +5. During emission, the `Visibility` access-specifier filter (Public/PublicAndProtected/All) + is applied by `CppEmitter` helper methods to include or exclude class members based on + their C++ access specifier. Regular members receive per-member pages (gradual-disclosure); case-insensitive collisions are combined onto a single lowercase page; operator overloads are detected by name and grouped onto shared class-level or namespace-level `operators.md` pages. diff --git a/docs/design/api-mark-cpp/cpp-emitter-single-file.md b/docs/design/api-mark-cpp/cpp-emitter-single-file.md index 667055d..fc36915 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/design/api-mark-cpp/cpp-emitter-single-file.md @@ -28,10 +28,10 @@ single `api.md` file. `IContext context`. - *Returns*: `void` - *Algorithm*: Calls `EmitSingleFile(factory, config)` which creates one writer - via `factory.CreateMarkdown("", "api")`, writes the H{depth} library-name - heading, optional description, and iterates over namespaces calling - `WriteSingleFileClassSection`, `WriteSingleFileFreeFunctionSection`, and - `WriteSingleFileEnumSection`. + via `factory.CreateMarkdown("", "api")`, writes the H{depth} library-name heading + in the form `{LibraryName} API Reference`, optional description, and iterates over + namespaces calling `WriteSingleFileClassSection`, `WriteSingleFileFreeFunctionSection`, + and `WriteSingleFileEnumSection`. **WriteSingleFileClassSection** (private): Emits an H{depth+2} section for a class. diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index f2e61f4..faf1b58 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -72,6 +72,9 @@ parse-time deprecated filter, builds the known-type map, and returns a system-header diagnostics; `CollectResultNamespace` groups declarations by namespace key; the known-type map is flattened from namespaces, nested classes, and type aliases; a `CppTypeLinkResolver` and `CppEmitter` are returned. + The constructed `CppTypeLinkResolver` accumulates references to external + (non-library, non-`std`) types encountered during `CppEmitter` execution; the + emitter renders them in an `External Types` section on each affected page. ### External Interfaces diff --git a/docs/design/api-mark-cpp/cpp-type-link-resolver.md b/docs/design/api-mark-cpp/cpp-type-link-resolver.md index bbb9b28..f84556b 100644 --- a/docs/design/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -37,6 +37,7 @@ fundamental C++ type names that must always remain plain text. last `::` in the original string (or 0 for unqualified types), calls `IndexOf(shortName, startIdx)` to locate the exact token, then reconstructs the result as `original[..idx] + linked + original[(idx + shortName.Length)..]`. + A null `currentFolder` is treated as an empty string (root-level file). - **FindPageKey** — performs exact qualified lookup first, then a short-name scan that returns null when the short name is ambiguous. - **StripQualifiers** — repeatedly removes leading and trailing `const`, @@ -47,8 +48,10 @@ fundamental C++ type names that must always remain plain text. ### Error Handling -N/A - the unit performs no I/O and returns inputs unchanged for unsupported or -empty values. +Constructor throws `ArgumentNullException` when `knownTypes` is null. `Linkify` throws +`ArgumentNullException` when `externalTypes` is null. All other unsupported input +(null/whitespace `cppTypeString`, empty stripped name) is handled by returning the original +value unchanged rather than throwing. ### External Interfaces diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml index a9c296a..bcaec62 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml @@ -33,7 +33,7 @@ sections: - CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter - CppEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly - id: ApiMarkCpp-CppEmitterSingleFile-EmitsLibraryNameHeading - title: CppEmitterSingleFile shall emit the library name as the top-level heading. + title: CppEmitterSingleFile shall emit the library name as the top-level heading in the form "{LibraryName} API Reference". justification: | The single-file document must identify the documented library immediately. tests: diff --git a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml index fbfb7b7..7b54b8a 100644 --- a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml @@ -57,7 +57,6 @@ sections: Primitive types are not documentation pages and must remain plain text. tests: - CppTypeLinkResolver_Linkify_PrimitiveType_ReturnsUnchanged - - CppTypeLinkResolver_Linkify_StdType_ReturnsUnchanged - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyStdType title: CppTypeLinkResolver.Linkify shall leave std:: types unchanged. justification: | diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index 728ccc2..0392f89 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -37,8 +37,9 @@ configuration beyond a standard clang installation is required. - Output files follow the naming convention: `api.md` entrypoint, `{namespace}.md` namespace summaries, `{namespace}/{TypeName}.md` type pages, `{namespace}/{AliasName}.md` type alias pages, `{namespace}/{TypeName}/{MemberName}.md` member detail pages, - `{namespace}/{TypeName}/{NestedType}.md` nested-type pages, and - `{namespace}/{TypeName}/{AliasName}.md` class-scoped type alias pages. + `{namespace}/{TypeName}/{NestedType}.md` nested-type pages, + `{namespace}/{TypeName}/{AliasName}.md` class-scoped type alias pages, and + `{namespace}/{TypeName}/operators.md` operator overloads pages. - When the single-file format is specified, all documentation is written to a single `api.md` file using a flat H1/H2/H3/H4 heading hierarchy. - `api.md` lists all namespaces in a table with a Declarations count column so AI agents can @@ -47,6 +48,12 @@ configuration beyond a standard clang installation is required. gradual-disclosure member pages and single-file output. - When output contains references to external (non-library, non-std) types, the generated documentation includes an External Types section listing those types alphabetically. +- All operator overloads for a class are grouped onto a single `operators.md` page at + `{namespace}/{TypeName}/operators.md`, and the owning type page includes a link to that page. +- Type strings in method and member table cells that match a documented intra-library type are + rendered as Markdown hyperlinks to the corresponding type page. +- Absolute `ApiHeaderPatterns` entries select headers by their full path without WorkingDirectory + resolution. ## Test Scenarios diff --git a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md index 3eee86d..19776a3 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md @@ -30,8 +30,8 @@ single-file output. This scenario is tested by **Emit enum section**: Verifies that enum declarations are rendered into the single-file output. This scenario is tested by `CppEmitterSingleFile_Emit_Enum_ContainsEnumSection`. -**Heading depth offset**: Verifies that member headings shift when a non-default heading depth -is configured. This scenario is tested by +**Heading depth offset**: Verifies that all heading levels — library, namespace, class, and +member — shift when a non-default heading depth is configured. This scenario is tested by `CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings`. **Creates exactly one writer keyed as api**: Verifies that the single-file emitter creates diff --git a/docs/verification/api-mark-cpp/cpp-generator.md b/docs/verification/api-mark-cpp/cpp-generator.md index 3700c80..d5bb7c8 100644 --- a/docs/verification/api-mark-cpp/cpp-generator.md +++ b/docs/verification/api-mark-cpp/cpp-generator.md @@ -29,6 +29,9 @@ Tests require the fixture headers and a system clang installation accessible on - Public type aliases, enums, nested classes, and single-file output are documented correctly. - Gitignore-style `ApiHeaderPatterns` (include, exclude, re-include, last-match-wins) are applied correctly to restrict the documented header set. +- Absolute `ApiHeaderPatterns` glob patterns are forwarded directly to `GlobFileCollector` + without WorkingDirectory resolution, allowing headers outside the project tree to be + documented. - Doxygen `@code`/`@endcode` blocks are rendered as fenced `cpp` code blocks on both gradual-disclosure member pages and single-file output. - `api.md` lists all namespaces with a declaration count column for AI navigation scope. @@ -36,25 +39,25 @@ Tests require the fixture headers and a system clang installation accessible on ### Test Scenarios **Inheritance signature includes base class**: Verifies that a derived class page includes its -base class in the rendered class declaration line. This scenario is tested by +base class in the rendered class declaration line. Tested by `CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature`. **Case-collision members share one page**: Verifies that members whose names differ only by case -are emitted on a single combined detail page. This scenario is tested by +are emitted on a single combined detail page. Tested by `CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage` and `CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers`. **Namespace operators share one page**: Verifies that namespace-level operator overloads are -combined onto one operators page. This scenario is tested by +combined onto one operators page. Tested by `CppGenerator_Generate_NamespaceFreeOperator_CreatesNamespaceOperatorsPage`. **Class-scoped type aliases are documented**: Verifies that class-level aliases receive pages and -are listed from the owning class page. This scenario is tested by +are listed from the owning class page. Tested by `CppGenerator_Generate_ClassScopedTypeAlias_CreatesAliasPage` and `CppGenerator_Generate_ClassScopedTypeAlias_ListedOnClassPage`. **CWD-relative header patterns are supported**: Verifies that relative `ApiHeaderPatterns` are -resolved from the current working directory. This scenario is tested by +resolved from the current working directory. Tested by `CppGenerator_Generate_ApiHeaderPatterns_CwdRelativePattern_OnlyMatchingFilesDocumented` and `CppGenerator_Generate_ApiHeaderPatterns_CwdRelativeExclusionPattern_ExcludesMatchingFiles`. @@ -134,3 +137,13 @@ the Methods table. Tested by case-insensitive collision exists, no separate page is created for the upper-case member name — only the combined lowercase page exists. Tested by `CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage`. + +**External type reference emits External Types section**: Verifies that when a documented type +references a non-library, non-`std` external type in its member table cells, the generated output +for that type page includes an `External Types` section listing the external type. Tested by +`CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection`. + +**Absolute header pattern documents matching file**: Verifies that an absolute-path +`ApiHeaderPatterns` entry selects the specified header for documentation without WorkingDirectory +resolution. Tested by +`CppGenerator_Generate_ApiHeaderPatterns_AbsolutePattern_DocumentsMatchingFile`. diff --git a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md index b4285cb..3c3ed84 100644 --- a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md @@ -33,6 +33,8 @@ the standard xUnit.net test runner. No clang installation is needed. - When a type name appears in a template argument position sharing a prefix with the resolved type (e.g., `Foo` where `Foo` is a known type), only the actual type token is wrapped in a Markdown link and the template argument is left unchanged. +- Leading `const`/`volatile` and trailing pointer/reference qualifiers are stripped from + the type name before any lookup is performed. ### Test Scenarios diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index bf09ec6..0b1a93e 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -68,6 +68,7 @@ internal sealed class CppTypeLinkResolver /// Dictionary mapping fully-qualified C++ type names (:: separators) to /// page keys (/ separators). Must not be null. /// + /// Thrown when is null. public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) { ArgumentNullException.ThrowIfNull(knownTypes); @@ -98,18 +99,19 @@ public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) /// A Markdown string: either a link of the form [Name](relative/path.md), /// or the original unchanged. /// + /// Thrown when is null. public string Linkify( string cppTypeString, string currentFolder, ISet externalTypes) { + ArgumentNullException.ThrowIfNull(externalTypes); + if (string.IsNullOrWhiteSpace(cppTypeString)) { return cppTypeString; } - ArgumentNullException.ThrowIfNull(externalTypes); - // Treat null currentFolder as an empty string (root-level file) currentFolder ??= string.Empty; diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 90a8e49..beafc28 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -9,7 +9,7 @@ namespace ApiMark.Cpp.Tests; /// /// Integration tests for using a shared -/// to avoid invoking clang more than 4 times per test run. +/// to avoid invoking clang more than five times per test run. /// public class CppGeneratorTests : IClassFixture { diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs index 73aade0..40ad2ee 100644 --- a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -229,28 +229,4 @@ public void CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplat Assert.DoesNotContain("[FooBar]", result, StringComparison.Ordinal); Assert.EndsWith("", result, StringComparison.Ordinal); } - - /// - /// Validates that when the type is not in the known-types dictionary, - /// returns the original string unchanged and records - /// the type in the external-types set so the caller can emit an External Types section. - /// - [Fact] - public void CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType() - { - // Arrange: a resolver with no known types, simulating a fully external reference - var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); - var externalTypes = new SortedSet(); - - // Act: resolve a type that belongs to a non-std namespace not in the known-types map - var result = resolver.Linkify("acme::Logger *", string.Empty, externalTypes); - - // Assert: the original type string is returned as-is (no link to an unknown page) - Assert.Equal("acme::Logger *", result); - - // Assert: the type is tracked in the external types set with the correct namespace - Assert.Single(externalTypes); - Assert.Equal("Logger", externalTypes.First().TypeString); - Assert.Equal("acme", externalTypes.First().Namespace); - } } From c785e354137c6e1dc3d02d0158be7ed6371f85a9 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 13:11:27 -0400 Subject: [PATCH 08/62] fix: Add subsystem requirement for External Types section The test CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection exercises the full Cpp pipeline (CppGenerator + CppTypeLinkResolver + CppEmitter) and is an integration test at the subsystem level. Add a top-level subsystem requirement ApiMarkCpp-EmitExternalTypesSection to api-mark-cpp.yaml and link the test to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/api-mark-cpp.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/reqstream/api-mark-cpp.yaml b/docs/reqstream/api-mark-cpp.yaml index 70579b4..d0b0f8d 100644 --- a/docs/reqstream/api-mark-cpp.yaml +++ b/docs/reqstream/api-mark-cpp.yaml @@ -38,3 +38,12 @@ sections: - ApiMarkCpp-CppEmitterSingleFile - ApiMarkCpp-CppTypeLinkResolver tests: [CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint] + - id: ApiMarkCpp-EmitExternalTypesSection + title: ApiMarkCpp shall render unrecognized non-std type references in an External + Types section on the affected page. + justification: | + When a documented type references a type outside the library and outside the + C++ standard library, readers need a clear indication of the external dependency. + The Cpp subsystem tracks these references during generation and emits a dedicated + External Types section so that the dependency is visible in the generated output. + tests: [CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection] From 838c0176d31d9b75cc34f9bb9eb40f8a084a0070 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 13:14:40 -0400 Subject: [PATCH 09/62] fix: Add class-scoped alias assertion and verification doc update (GD R6 F-01) Add Assert.True(factory.HasWriter("testlib/Widget", "size_type")) to Emit_TypeAlias_CreatesTypeAliasPage so the class-scoped alias path is explicitly verified. Update the verification doc scenario and acceptance criterion to cover both namespace-level and class-scoped type aliases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-mark-cpp/cpp-emitter-gradual-disclosure.md | 9 +++++---- .../CppEmitterGradualDisclosureTests.cs | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md index 74c3e0f..e4424b7 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -15,7 +15,7 @@ Tests run with the standard xUnit.net test runner. - The emitter creates the api index, namespace summary, and type pages. - The emitter creates detail pages for visible members and free functions. -- The emitter creates enum pages, type-alias pages, nested-type pages, and operator pages. +- The emitter creates enum pages, type-alias pages (namespace-level and class-scoped), nested-type pages, and operator pages. - Case-insensitive collisions are combined onto one page. - Empty namespace collections still produce an `api.md` fallback page. - The api index page heading contains the configured library name. @@ -31,9 +31,10 @@ from a single deterministic location. Tested by `{namespace}/{EnumName}` with an H1 heading matching the enum name and a values table listing all enumerators. Tested by `CppEmitterGradualDisclosure_Emit_Enum_CreatesEnumPage`. -**Type alias page**: Verifies that a namespace-level `using` type alias declaration produces its -own page at `{namespace}/{AliasName}` containing the `using` declaration in a signature block. -Tested by `CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage`. +**Type alias page**: Verifies that both namespace-level and class-scoped `using` type alias +declarations produce their own pages at `{namespace}/{AliasName}` and +`{namespace}/{ClassName}/{AliasName}` respectively, each containing the `using` declaration in a +signature block. Tested by `CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage`. **Nested class page**: Verifies that a class nested inside a public outer class produces its own page at `{namespace}/{OuterType}/{NestedType}` so readers can navigate to it directly from the diff --git a/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs index 8251e3a..97c2517 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs @@ -213,6 +213,7 @@ public void CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage() // Assert Assert.True(factory.HasWriter("testlib", "widget_id_t")); + Assert.True(factory.HasWriter("testlib/Widget", "size_type")); } /// Validates that nested classes receive pages under their parent class folder. From 158c60a18f87f750b667fe3281bd1fc6c8bce138 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 14:21:05 -0400 Subject: [PATCH 10/62] fix: Wire EmitExternalTypesSection into requirement decomposition hierarchy Add ApiMarkCpp-EmitExternalTypesSection to the parent requirement's children list and add a children list pointing to the two implementing unit requirements (CppEmitter-RepresentExternalTypeInfo, CppTypeLinkResolver-LinkifyExternalTypeTracking) to satisfy structural traceability (AllRequirements R7 findings R7-01, R7-02). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/api-mark-cpp.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reqstream/api-mark-cpp.yaml b/docs/reqstream/api-mark-cpp.yaml index d0b0f8d..55cf9f0 100644 --- a/docs/reqstream/api-mark-cpp.yaml +++ b/docs/reqstream/api-mark-cpp.yaml @@ -37,6 +37,7 @@ sections: - ApiMarkCpp-CppEmitterGradualDisclosure - ApiMarkCpp-CppEmitterSingleFile - ApiMarkCpp-CppTypeLinkResolver + - ApiMarkCpp-EmitExternalTypesSection tests: [CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint] - id: ApiMarkCpp-EmitExternalTypesSection title: ApiMarkCpp shall render unrecognized non-std type references in an External @@ -46,4 +47,7 @@ sections: C++ standard library, readers need a clear indication of the external dependency. The Cpp subsystem tracks these references during generation and emits a dedicated External Types section so that the dependency is visible in the generated output. + children: + - ApiMarkCpp-CppEmitter-RepresentExternalTypeInfo + - ApiMarkCpp-CppTypeLinkResolver-LinkifyExternalTypeTracking tests: [CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection] From 8c04053248cf9ed1764f2df8aa183e636827959a Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 14:26:20 -0400 Subject: [PATCH 11/62] fix: Batch 3 Cpp reviews Architecture/Verification R7 direct doc fixes - Design: add CppEmitterGradualDisclosure/SingleFile->CppEmitter back-edges - Architecture: remove CppAstModel from Dependencies (internal unit, not OTS) - Verification: expand operator criterion and naming convention to include namespace-level operators.md; add namespace-level operator scenario; add absolute-pattern scenario; normalize 'This scenario is tested by' prose in cpp-ast-model.md, cpp-emitter.md, cpp-emitter-single-file.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 4 ++-- docs/verification/api-mark-cpp.md | 17 +++++++++++++++-- docs/verification/api-mark-cpp/cpp-ast-model.md | 2 +- .../api-mark-cpp/cpp-emitter-single-file.md | 8 ++++---- docs/verification/api-mark-cpp/cpp-emitter.md | 2 +- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index a3a8f93..0e5813a 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -31,6 +31,8 @@ flowchart TD CppGenerator --> CppEmitter CppEmitter --> CppEmitterGradualDisclosure CppEmitter --> CppEmitterSingleFile + CppEmitterGradualDisclosure --> CppEmitter + CppEmitterSingleFile --> CppEmitter CppEmitter --> CppTypeLinkResolver CppEmitter --> IMarkdownWriterFactory CppGenerator --> CppTypeLinkResolver @@ -101,8 +103,6 @@ flowchart TD - **clang** — external AST parser used through `ClangAstParser`. - **ApiMarkCore** — provides `IApiGenerator`, `IApiEmitter`, `EmitConfig`, `IContext`, `IMarkdownWriterFactory`, and `GlobFileCollector`. -- **CppAstModel** — immutable record types that carry parsed declarations from - `ClangAstParser` through `CppGenerator` to `CppEmitter`. ## Risk Control Measures diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index 0392f89..7f34755 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -38,8 +38,9 @@ configuration beyond a standard clang installation is required. summaries, `{namespace}/{TypeName}.md` type pages, `{namespace}/{AliasName}.md` type alias pages, `{namespace}/{TypeName}/{MemberName}.md` member detail pages, `{namespace}/{TypeName}/{NestedType}.md` nested-type pages, - `{namespace}/{TypeName}/{AliasName}.md` class-scoped type alias pages, and - `{namespace}/{TypeName}/operators.md` operator overloads pages. + `{namespace}/{TypeName}/{AliasName}.md` class-scoped type alias pages, + `{namespace}/{TypeName}/operators.md` class operator overloads pages, and + `{namespace}/operators.md` namespace-level operator overloads pages. - When the single-file format is specified, all documentation is written to a single `api.md` file using a flat H1/H2/H3/H4 heading hierarchy. - `api.md` lists all namespaces in a table with a Declarations count column so AI agents can @@ -50,6 +51,8 @@ configuration beyond a standard clang installation is required. documentation includes an External Types section listing those types alphabetically. - All operator overloads for a class are grouped onto a single `operators.md` page at `{namespace}/{TypeName}/operators.md`, and the owning type page includes a link to that page. +- Namespace-level free-function operator overloads are grouped onto a single `operators.md` + page at `{namespace}/operators.md`. - Type strings in method and member table cells that match a documented intra-library type are rendered as Markdown hyperlinks to the corresponding type page. - Absolute `ApiHeaderPatterns` entries select headers by their full path without WorkingDirectory @@ -225,6 +228,11 @@ tested by `CppGenerator_Generate_ClassWithOperators_CreatesOperatorsPage`, `CppGenerator_Generate_ClassWithOperators_OperatorsPageContainsOperatorEntry`, and `CppGenerator_Generate_ClassWithOperators_TypePageLinksToOperatorsPage`. +**Namespace-level free-function operators grouped on single operators page**: Verifies that +namespace-level free-function operator overloads are grouped onto a single `operators.md` page +at `{namespace}/operators` rather than producing individual pages. Tested by +`CppGenerator_Generate_NamespaceFreeOperator_CreatesNamespaceOperatorsPage`. + **Intra-library return type emits Markdown link in table cell**: Verifies that a method whose return type is a documented intra-library type produces a Markdown hyperlink in the Returns column of the Methods table. This scenario is tested by @@ -238,6 +246,11 @@ This scenario is tested by `CppGenerator_Generate_NoApiHeaderPatterns_DocumentsA `CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader`, and `CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_ExcludesHeader`. +**Absolute header patterns select headers by full path**: Verifies that an `ApiHeaderPatterns` +entry containing an absolute path selects the specified header for documentation without +WorkingDirectory resolution, allowing headers outside the project tree to be included. Tested by +`CppGenerator_Generate_ApiHeaderPatterns_AbsolutePattern_DocumentsMatchingFile`. + **Code example blocks rendered as fenced code**: Verifies that Doxygen `@code`/`@endcode` blocks on documented methods are rendered as fenced `cpp` code blocks on both gradual-disclosure member pages and single-file output. This scenario is tested by diff --git a/docs/verification/api-mark-cpp/cpp-ast-model.md b/docs/verification/api-mark-cpp/cpp-ast-model.md index b5cc724..860239e 100644 --- a/docs/verification/api-mark-cpp/cpp-ast-model.md +++ b/docs/verification/api-mark-cpp/cpp-ast-model.md @@ -24,7 +24,7 @@ Tests run with the standard xUnit.net test runner. **CppDocComment note/example default to null**: Verifies that constructing `CppDocComment` without `Note` or `Example` leaves both properties `null`, matching the documented optional -metadata contract. This scenario is tested by +metadata contract. Tested by `CppDocComment_NoteAndExample_WhenNotProvided_AreNull`. **Record types store constructor parameters**: Verifies that `CppField`, `CppFunction`, and diff --git a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md index 19776a3..e53b495 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md @@ -21,17 +21,17 @@ Tests run with the standard xUnit.net test runner. ### Test Scenarios **Emit class section**: Verifies that class data is rendered into a dedicated class section. -This scenario is tested by `CppEmitterSingleFile_Emit_ClassData_ContainsClassSection`. +Tested by `CppEmitterSingleFile_Emit_ClassData_ContainsClassSection`. **Emit free-function section**: Verifies that namespace free functions are rendered into the -single-file output. This scenario is tested by +single-file output. Tested by `CppEmitterSingleFile_Emit_FreeFunction_ContainsFreeFunctionSection`. **Emit enum section**: Verifies that enum declarations are rendered into the single-file -output. This scenario is tested by `CppEmitterSingleFile_Emit_Enum_ContainsEnumSection`. +output. Tested by `CppEmitterSingleFile_Emit_Enum_ContainsEnumSection`. **Heading depth offset**: Verifies that all heading levels — library, namespace, class, and -member — shift when a non-default heading depth is configured. This scenario is tested by +member — shift when a non-default heading depth is configured. Tested by `CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings`. **Creates exactly one writer keyed as api**: Verifies that the single-file emitter creates diff --git a/docs/verification/api-mark-cpp/cpp-emitter.md b/docs/verification/api-mark-cpp/cpp-emitter.md index 6ea7dfc..88762f3 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter.md +++ b/docs/verification/api-mark-cpp/cpp-emitter.md @@ -27,7 +27,7 @@ Tests run with the standard xUnit.net test runner. ### Test Scenarios **Sanitize invalid characters**: Verifies that invalid file-name characters are replaced with -underscores instead of causing output-path failures. This scenario is tested by +underscores instead of causing output-path failures. Tested by `CppEmitter_SanitizeFileName_InvalidCharacters_AreReplacedWithUnderscore`. **Null factory rejection**: Verifies that passing null to `CppEmitter.Emit` throws From 38ff210943310d00c85f101367391584f994ba75 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 14:35:13 -0400 Subject: [PATCH 12/62] fix: Batch 3 Cpp reviews R7/R8 fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp/cpp-generator.md | 4 +++- .../api-mark-cpp/cpp-type-link-resolver.md | 3 +++ .../api-mark-cpp/cpp-type-link-resolver.md | 15 ++++++++----- .../CppEmitterGradualDisclosure.cs | 18 +++++++-------- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 9 ++++---- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 15 ++++--------- .../CppTypeLinkResolverTests.cs | 22 +++++++++++++++---- 7 files changed, 52 insertions(+), 34 deletions(-) diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index faf1b58..0d4bd40 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -23,7 +23,9 @@ single-file or gradual-disclosure Markdown output. - `ApiHeaderPatterns`: `IList` — ordered gitignore-style include/exclude patterns used to select the documented public headers. Relative patterns are resolved from `WorkingDirectory` or the process CWD when `WorkingDirectory` is - null. + null. Absolute patterns are forwarded directly to `GlobFileCollector` without + WorkingDirectory resolution, allowing headers outside the project tree to be + selected. - `WorkingDirectory`: `string?` — optional working-directory anchor for resolving relative `ApiHeaderPatterns`; default `null`, which means the current process working directory is used. diff --git a/docs/design/api-mark-cpp/cpp-type-link-resolver.md b/docs/design/api-mark-cpp/cpp-type-link-resolver.md index f84556b..274cad8 100644 --- a/docs/design/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -68,3 +68,6 @@ N/A - in-process utility class only. - **CppEmitter** — stores and forwards the resolver. - **CppEmitterGradualDisclosure** — calls `Linkify` for return-type, parameter, field-type, and alias-type cells. +- **CppEmitterSingleFile** — calls `Linkify` to resolve type strings in + single-file member table cells, producing Markdown links for intra-library + types. diff --git a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md index 3c3ed84..4668b60 100644 --- a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md @@ -3,11 +3,14 @@ ### Verification Approach `CppTypeLinkResolver` is unit-tested in `test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs` -without any test doubles. The resolver is stateless with respect to link resolution itself; -each test constructs a resolver with a controlled `knownTypes` dictionary and supplies a -fresh `SortedSet` as the external-type accumulator. Tests cover the -exact-qualified-match path, the unambiguous short-name fallback, the ambiguous short-name -non-link path, and the qualified-reference disambiguation path. +using an `InMemoryMarkdownWriterFactory` substitute and pre-configured known-type +dictionaries. Tests cover: null/whitespace input pass-through, primitive-type +pass-through, `std::` type pass-through, exact qualified-name match, unambiguous +short-name fallback, ambiguous short-name non-link, qualified-reference +disambiguation, external-type tracking, qualifier stripping +(`const`/`volatile`/pointer/reference), template-prefix corruption prevention, +null `knownTypes` constructor rejection, and null `externalTypes` `Linkify` +rejection. ### Test Environment @@ -35,6 +38,8 @@ the standard xUnit.net test runner. No clang installation is needed. token is wrapped in a Markdown link and the template argument is left unchanged. - Leading `const`/`volatile` and trailing pointer/reference qualifiers are stripped from the type name before any lookup is performed. +- Constructor throws `ArgumentNullException` when `knownTypes` is null. +- `Linkify` throws `ArgumentNullException` when `externalTypes` is null. ### Test Scenarios diff --git a/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs b/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs index 45ec03c..f29830b 100644 --- a/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs +++ b/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs @@ -395,7 +395,7 @@ private static void ProcessClassMethodMember( var methodSummary = CppEmitter.GetSummary(method.Doc) ?? CppEmitter.NoDescriptionPlaceholder; // Linkify the return type cell using the type link resolver - var returnType = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(method.ReturnTypeName), ctx.NsKey, externalTypes); + var returnType = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(method.ReturnTypeName), ctx.NsKey, externalTypes)!; if (writtenKeys.Add(lowerKey)) { @@ -434,7 +434,7 @@ private static void ProcessClassFieldMember( var fieldSummary = CppEmitter.GetSummary(field.Doc) ?? CppEmitter.NoDescriptionPlaceholder; // Linkify the field type cell using the type link resolver - var typeName = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(field.TypeName), ctx.NsKey, externalTypes); + var typeName = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(field.TypeName), ctx.NsKey, externalTypes)!; if (writtenKeys.Add(lowerKey)) { @@ -519,7 +519,7 @@ private void WriteClassMemberTables( .Select(alias => { var summary = CppEmitter.GetSummary(alias.Doc) ?? CppEmitter.NoDescriptionPlaceholder; - var underlying = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName), ctx.NsKey, externalTypes); + var underlying = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName), ctx.NsKey, externalTypes)!; return new[] { $"[{alias.Name}]({ctx.Class.Name}/{alias.Name}.md)", underlying, summary }; }); writer.WriteTable(aliasHeaders, aliasRows); @@ -718,7 +718,7 @@ private void WriteFreeFunctionContent( // Linkify parameter type cells; resolver tracks external types encountered var paramRows = fn.Parameters.Select(p => - new[] { p.Name, cppResolver.Linkify(CppEmitter.SimplifyTypeName(p.TypeName), currentFolder, externalTypes), CppEmitter.GetParamDescription(fn.Doc, p.Name) ?? CppEmitter.NoDescriptionPlaceholder }); + new[] { p.Name, cppResolver.Linkify(CppEmitter.SimplifyTypeName(p.TypeName), currentFolder, externalTypes)!, CppEmitter.GetParamDescription(fn.Doc, p.Name) ?? CppEmitter.NoDescriptionPlaceholder }); writer.WriteTable(paramHeaders, paramRows); } @@ -727,7 +727,7 @@ private void WriteFreeFunctionContent( if (!string.Equals(returnTypeName, "void", StringComparison.Ordinal)) { // Always linkify/track the return type even when a doc description is present - var linkedReturnType = cppResolver.Linkify(returnTypeName, currentFolder, externalTypes); + var linkedReturnType = cppResolver.Linkify(returnTypeName, currentFolder, externalTypes)!; writer.WriteHeading(parametersHeadingLevel, "Returns"); var returnDescription = CppEmitter.GetReturnDescription(fn.Doc); writer.WriteParagraph(!string.IsNullOrEmpty(returnDescription) ? returnDescription : linkedReturnType); @@ -833,7 +833,7 @@ internal static void WriteFunctionContent( // Linkify parameter type cells; resolver tracks external types encountered var paramRows = method.Parameters.Select(p => - new[] { p.Name, ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(p.TypeName), ctx.CurrentFolder, ctx.ExternalTypes), CppEmitter.GetParamDescription(method.Doc, p.Name) ?? CppEmitter.NoDescriptionPlaceholder }); + new[] { p.Name, ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(p.TypeName), ctx.CurrentFolder, ctx.ExternalTypes)!, CppEmitter.GetParamDescription(method.Doc, p.Name) ?? CppEmitter.NoDescriptionPlaceholder }); writer.WriteTable(paramHeaders, paramRows); } @@ -845,7 +845,7 @@ internal static void WriteFunctionContent( if (!string.Equals(returnTypeName, "void", StringComparison.Ordinal)) { // Always linkify/track the return type even when a doc description is present - var linkedReturnType = ctx.CppResolver.Linkify(returnTypeName, ctx.CurrentFolder, ctx.ExternalTypes); + var linkedReturnType = ctx.CppResolver.Linkify(returnTypeName, ctx.CurrentFolder, ctx.ExternalTypes)!; writer.WriteHeading(ctx.ParametersHeadingLevel, "Returns"); var returnDescription = CppEmitter.GetReturnDescription(method.Doc); writer.WriteParagraph(!string.IsNullOrEmpty(returnDescription) ? returnDescription : linkedReturnType); @@ -1096,7 +1096,7 @@ private static void WriteNamespacePage( .Select(alias => { var summary = CppEmitter.GetSummary(alias.Doc) ?? CppEmitter.NoDescriptionPlaceholder; - var underlying = cppResolver.Linkify(CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName), nsKey, externalTypes); + var underlying = cppResolver.Linkify(CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName), nsKey, externalTypes)!; return new[] { $"[{alias.Name}]({nsKey}/{alias.Name}.md)", underlying, summary }; }); writer.WriteTable(aliasHeaders, aliasRows); @@ -1125,7 +1125,7 @@ private static void WriteNamespacePage( // Linkify the return type cell; namespace page folder is "" (root) var returnType = cppResolver.Linkify( - CppEmitter.SimplifyTypeName(fn.ReturnTypeName), string.Empty, externalTypes); + CppEmitter.SimplifyTypeName(fn.ReturnTypeName), string.Empty, externalTypes)!; var safeName = CppEmitter.SanitizeFileName(fn.Name); return new[] { $"[{fn.Name}]({nsKey}/{safeName}.md)", returnType, summary }; }); diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index 0b1a93e..4343048 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -96,12 +96,13 @@ public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) /// Types" section after all table rows have been written. Must not be null. /// /// - /// A Markdown string: either a link of the form [Name](relative/path.md), - /// or the original unchanged. + /// Returns the resolved Markdown link if the type is a known intra-library type; + /// otherwise returns the original value. Returns when + /// is passed. /// /// Thrown when is null. - public string Linkify( - string cppTypeString, + public string? Linkify( + string? cppTypeString, string currentFolder, ISet externalTypes) { diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index beafc28..562b2a4 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1486,19 +1486,12 @@ public void CppGenerator_Generate_ClassScopedTypeAlias_DoesNotCollideAcrossClass [Fact] public void CppGenerator_Generate_SingleFileOutput_WritesSingleApiMarkdown() { - // Arrange: run a fresh generator with SingleFile format — the shared fixture uses - // GradualDisclosure and cannot be reused for this test - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Parse(new InMemoryContext()).Emit( - factory, - new EmitConfig { Format = OutputFormat.SingleFile }, - new InMemoryContext()); + // Arrange: use the pre-generated single-file fixture factory — it emits with + // OutputFormat.SingleFile over the same fixture headers as all other tests + var factory = _fixture.PublicSingleFileFactory; // Assert: exactly one writer, keyed "api" - Assert.Single(factory.Writers); + Assert.Single(_fixture.PublicSingleFileFactory.Writers); Assert.True(factory.Writers.TryGetValue("api", out var writer), "Expected a single api writer for single-file output"); var headings = writer.Operations.OfType().ToList(); diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs index 40ad2ee..838edb6 100644 --- a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -143,7 +143,7 @@ public void CppTypeLinkResolver_Linkify_NullInput_ReturnsNull() var externalTypes = new SortedSet(); // Act - var result = resolver.Linkify(null!, string.Empty, externalTypes); + var result = resolver.Linkify(null, string.Empty, externalTypes); // Assert Assert.Null(result); @@ -206,9 +206,8 @@ public void CppTypeLinkResolver_Linkify_QualifiedType_StripsQualifiersBeforeLook } /// - /// Validates that when a type name (e.g. "Foo") appears both as the intra-library type - /// being linked and as a prefix of a template argument (e.g. "FooBar"), only the - /// actual type token is wrapped in a link and the template argument is left unchanged. + /// Validates that the template-argument prefix corruption prevention algorithm + /// links only the actual type token and leaves a sharing-prefix template argument unchanged. /// [Fact] public void CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplateArg_EmitsLinkWithoutCorruption() @@ -229,4 +228,19 @@ public void CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplat Assert.DoesNotContain("[FooBar]", result, StringComparison.Ordinal); Assert.EndsWith("", result, StringComparison.Ordinal); } + + /// Validates that the constructor throws when knownTypes is null. + [Fact] + public void CppTypeLinkResolver_Constructor_NullKnownTypes_ThrowsArgumentNullException() + { + Assert.Throws(() => new CppTypeLinkResolver(null!)); + } + + /// Validates that Linkify throws when externalTypes is null. + [Fact] + public void CppTypeLinkResolver_Linkify_NullExternalTypes_ThrowsArgumentNullException() + { + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + Assert.Throws(() => resolver.Linkify("SomeType", string.Empty, null!)); + } } From 41c6c569da80b9199c7b70adc6389803c5ac0fb7 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 14:46:07 -0400 Subject: [PATCH 13/62] fix: Batch 3 Cpp reviews R8/R9 fixes Design: - Remove CppEmitterSingleFile->CppTypeLinkResolver diagram edge (SingleFile accepts but discards the resolver; type links are omitted in single-file mode) TLR design: - Remove CppEmitterSingleFile from Callers section (confirmed unused) - Add empty-stripped-name early-return note to Linkify Key Methods TLR tests: - Add AAA comments to NullKnownTypes and NullExternalTypes null-guard tests TLR verification: - Add test scenarios for constructor null-guard and Linkify null externalTypes - Normalize 4x 'This scenario is tested by' -> 'Tested by' System verification (api-mark-cpp.md): - Normalize 31x 'This scenario is tested by' -> 'Tested by' - Add namespace/operators.md rows to user guide output structure table GD verification: - Clarify operator criterion: class-level and namespace-level operator pages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 1 - .../api-mark-cpp/cpp-type-link-resolver.md | 11 ++-- docs/user_guide/cpp.md | 2 + docs/verification/api-mark-cpp.md | 62 +++++++++---------- .../cpp-emitter-gradual-disclosure.md | 3 +- .../api-mark-cpp/cpp-type-link-resolver.md | 16 +++-- .../CppTypeLinkResolverTests.cs | 4 ++ 7 files changed, 56 insertions(+), 43 deletions(-) diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index 0e5813a..95457f7 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -40,7 +40,6 @@ flowchart TD CppEmitterGradualDisclosure --> CppAstModel CppEmitterSingleFile --> CppAstModel CppEmitterGradualDisclosure --> CppTypeLinkResolver - CppEmitterSingleFile --> CppTypeLinkResolver CppEmitterGradualDisclosure --> IMarkdownWriterFactory CppEmitterSingleFile --> IMarkdownWriterFactory ``` diff --git a/docs/design/api-mark-cpp/cpp-type-link-resolver.md b/docs/design/api-mark-cpp/cpp-type-link-resolver.md index 274cad8..d7d0c39 100644 --- a/docs/design/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -29,9 +29,11 @@ fundamental C++ type names that must always remain plain text. ### Key Methods - **Linkify** — returns the original string unchanged for null/whitespace input, - primitives, and `std::` types; resolves exact qualified matches first; falls back - to an unambiguous short-name match; when no known type matches and the stripped - name has a non-empty non-`std` namespace, records a `CppExternalTypeInfo` entry. + primitives, and `std::` types; also returns the original value unchanged when + qualifier stripping yields an empty string (degenerate input); resolves exact + qualified matches first; falls back to an unambiguous short-name match; when no + known type matches and the stripped name has a non-empty non-`std` namespace, + records a `CppExternalTypeInfo` entry. For qualified type strings, the splice uses a position-aware algorithm to prevent template-argument prefix corruption: derives `startIdx` as the position after the last `::` in the original string (or 0 for unqualified types), calls @@ -68,6 +70,3 @@ N/A - in-process utility class only. - **CppEmitter** — stores and forwards the resolver. - **CppEmitterGradualDisclosure** — calls `Linkify` for return-type, parameter, field-type, and alias-type cells. -- **CppEmitterSingleFile** — calls `Linkify` to resolve type strings in - single-file member table cells, producing Markdown links for intra-library - types. diff --git a/docs/user_guide/cpp.md b/docs/user_guide/cpp.md index 6be5940..b38d01c 100644 --- a/docs/user_guide/cpp.md +++ b/docs/user_guide/cpp.md @@ -192,6 +192,8 @@ A hierarchy of Markdown files designed for incremental context loading. | `{namespace}/{type}/{member}.md` | Member detail page — full signature, parameters, return value, remarks, example | | `{namespace}/{type}/{nested-type}.md` | Nested type page — same structure as a top-level type page | | `{namespace}/{type}/{alias}.md` | Class-scoped type alias page — alias declared inside a class body | +| `{namespace}/{type}/operators.md` | Class operator overloads page — all operator overloads for a type on one page | +| `{namespace}/operators.md` | Namespace operator overloads page — free-function operator overloads for a namespace | An AI agent can read the root index first, drill into the relevant namespace or type page, and then read the member detail — consuming only as much context as the task diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index 7f34755..344f908 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -62,83 +62,83 @@ configuration beyond a standard clang installation is required. **Valid headers create the api entrypoint file**: Verifies that the generator creates the top-level `api.md` entrypoint file when run against real fixture headers, confirming the full generation path -from header parsing to file emission is wired correctly. This scenario is tested by +from header parsing to file emission is wired correctly. Tested by `CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint`. **Valid headers create a namespace summary page**: Verifies that the generator creates a namespace summary page for each namespace discovered in the fixture headers so all owned declarations are -reachable from the entrypoint. This scenario is tested by +reachable from the entrypoint. Tested by `CppGenerator_Generate_ValidHeaders_CreatesNamespacePage`. **Valid headers create a type page for SampleClass**: Verifies that a representative class defined in the fixture headers receives its own type page, confirming the ownership filter and type page -generation path are correct. This scenario is tested by +generation path are correct. Tested by `CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass`. **Inheritance class includes base type in signature**: Verifies that a class with a public base class -has the base class name included in the generated type page class signature. This scenario is tested by +has the base class name included in the generated type page class signature. Tested by `CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature`. **Deprecated class is excluded when IncludeDeprecated is false**: Verifies that a class marked `[[deprecated]]` does not receive a type page when the IncludeDeprecated option is false, -confirming the default exclude behavior. This scenario is tested by +confirming the default exclude behavior. Tested by `CppGenerator_Generate_IncludeDeprecatedFalse_ExcludesDeprecatedClass`. **Deprecated class is included when IncludeDeprecated is true**: Verifies that a class marked `[[deprecated]]` receives a type page when IncludeDeprecated is explicitly set to true, -confirming the opt-in include behavior. This scenario is tested by +confirming the opt-in include behavior. Tested by `CppGenerator_Generate_IncludeDeprecatedTrue_IncludesDeprecatedClass`. **Protected method is excluded under Public visibility**: Verifies that a protected method does not receive its own member page when the visibility is set to Public, confirming the access -specifier filter is applied correctly for the public-only audience. This scenario is tested by +specifier filter is applied correctly for the public-only audience. Tested by `CppGenerator_Generate_PublicVisibility_ExcludesProtectedMethod`. **Protected method is included under PublicAndProtected visibility**: Verifies that a protected method receives its own member page when visibility is PublicAndProtected, confirming that the -broader visibility mode includes the expected members. This scenario is tested by +broader visibility mode includes the expected members. Tested by `CppGenerator_Generate_PublicAndProtectedVisibility_IncludesProtectedMethod`. **Private method is included under All visibility**: Verifies that a private method receives its own member page when visibility is All, confirming that all access specifiers are included in the -most permissive mode. This scenario is tested by +most permissive mode. Tested by `CppGenerator_Generate_AllVisibility_IncludesPrivateMethod`. **Method with parameters creates a member page**: Verifies that a method with parameters receives its own dedicated member detail page, confirming that parameterized members are handled by the -member page emission path. This scenario is tested by +member page emission path. Tested by `CppGenerator_Generate_MethodWithParameters_CreatesMemberPage`. **All members receive separate files**: Verifies that every visible member — including parameterless methods and free functions — is emitted as a separate file, making navigation fully -deterministic without requiring callers to know member shape. This scenario is tested by +deterministic without requiring callers to know member shape. Tested by `CppGenerator_AllMembers_GetSeparateFiles`. **Output files follow naming convention**: Verifies that generated file keys follow the expected naming convention: `api` entrypoint, `{namespace}` namespace summaries, `{namespace}/{TypeName}` -type pages, and `{namespace}/{TypeName}/{MemberName}` member pages. This scenario is tested by +type pages, and `{namespace}/{TypeName}/{MemberName}` member pages. Tested by `CppGenerator_OutputFiles_FollowNamingConvention`. **Type with doc comment writes summary to paragraph**: Verifies that the Doxygen `@brief` comment on a documented class is extracted and rendered as a description paragraph in the type page output. -This scenario is tested by `CppGenerator_Generate_TypeWithDocComment_WritesSummaryToParagraph`. +Tested by `CppGenerator_Generate_TypeWithDocComment_WritesSummaryToParagraph`. **Method with doc comment writes summary to paragraph**: Verifies that the Doxygen `@brief` comment on a documented method is extracted and rendered as a description paragraph in the member -page output. This scenario is tested by +page output. Tested by `CppGenerator_Generate_MethodWithDocComment_WritesSummaryToParagraph`. **Missing doc comment writes placeholder text**: Verifies that a member with no Doxygen doc comment emits the standard no-description placeholder rather than an empty or absent description -field. This scenario is tested by `CppGenerator_Generate_MissingDocComment_WritesPlaceholder`. +field. Tested by `CppGenerator_Generate_MissingDocComment_WritesPlaceholder`. **Free functions receive their own pages**: Verifies that free functions in a namespace receive dedicated pages at `{namespace}/{functionName}`, parallel to the member page convention for class -members. This scenario is tested by `CppGenerator_Generate_FreeFunctions_GetOwnPages`. +members. Tested by `CppGenerator_Generate_FreeFunctions_GetOwnPages`. **Valid headers create an enum page**: Verifies that an enum declared in the fixture headers receives its own type page following the same `{namespace}/{typeName}` convention as classes. -This scenario is tested by `CppGenerator_Generate_ValidHeaders_CreatesEnumPage`. +Tested by `CppGenerator_Generate_ValidHeaders_CreatesEnumPage`. **Enum page contains all declared values**: Verifies that the enum type page includes all declared enum value names so the complete enumeration is visible in the generated reference. This scenario @@ -146,7 +146,7 @@ is tested by `CppGenerator_Generate_EnumPage_ContainsValues`. **Template class creates a type page**: Verifies that a primary class template receives its own type page, confirming that template declarations are handled by the ownership filter and type page -emission path. This scenario is tested by `CppGenerator_Generate_TemplateClass_CreatesTypePage`. +emission path. Tested by `CppGenerator_Generate_TemplateClass_CreatesTypePage`. **Inheritance class creates a type page**: Verifies that a class that inherits from another class receives its own type page, confirming that derived types are documented independently. This @@ -158,27 +158,27 @@ scenario is tested by `CppGenerator_Generate_Constructor_CreatesConstructorPage` **Type page contains fully qualified C++ name**: Verifies that the type page signature block contains the fully qualified C++ name (e.g. `fixtures::SampleClass`) so an AI reader knows -exactly how to reference the type in code. This scenario is tested by +exactly how to reference the type in code. Tested by `CppGenerator_Generate_TypePage_ContainsQualifiedName`. **Member page contains fully qualified C++ name**: Verifies that the member page signature block contains the fully qualified C++ name (e.g. `fixtures::SampleClass::GetGreeting`) so an AI reader -can call the member without guessing the namespace. This scenario is tested by +can call the member without guessing the namespace. Tested by `CppGenerator_Generate_MemberPage_ContainsQualifiedName`. **Variadic function creates its own page**: Verifies that a variadic free function declared with `...` receives its own dedicated page, confirming that variadic functions are handled correctly -by the free-function emission path. This scenario is tested by +by the free-function emission path. Tested by `CppGenerator_Generate_VariadicFunction_CreatesPage`. **Constructor throws when options are null**: Verifies that passing a null options object to the CppGenerator constructor throws `ArgumentNullException` immediately, so misconfigured callers fail -fast before any I/O is attempted. This scenario is tested by +fast before any I/O is attempted. Tested by `CppGenerator_Constructor_NullOptions_ThrowsArgumentNullException`. **Generate throws when factory is null**: Verifies that passing a null factory to Generate throws `ArgumentNullException`, so callers that forget to supply a factory receive a clear error rather -than an unrelated null-reference failure during I/O. This scenario is tested by +than an unrelated null-reference failure during I/O. Tested by `CppGenerator_Generate_NullFactory_ThrowsArgumentNullException`. **Generate throws when include root does not exist**: Verifies that Generate throws @@ -198,17 +198,17 @@ tested by `CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix **Type aliases receive their own pages**: Verifies that `using` type alias declarations in documented namespaces produce individual pages at `{namespace}/{aliasName}`, following the same -convention as class and enum pages. This scenario is tested by +convention as class and enum pages. Tested by `CppGenerator_Generate_TypeAlias_CreatesAliasPages`. **Type alias page contains declaration and summary**: Verifies that the type alias page contains the `using {name} = {underlying}` declaration in a fenced code block and the Doxygen `@brief` -summary as a description paragraph. This scenario is tested by +summary as a description paragraph. Tested by `CppGenerator_Generate_TypeAliasPage_ContainsDeclarationAndSummary`. **Namespace page lists type aliases**: Verifies that the namespace summary page includes a "Type Aliases" section that lists every owned alias so readers can discover them without opening -individual alias pages. This scenario is tested by +individual alias pages. Tested by `CppGenerator_Generate_NamespacePage_ListsTypeAliases`. **Single-file format writes all namespaces to one api.md file**: Verifies that when the @@ -218,7 +218,7 @@ scenario is tested by `CppGenerator_Generate_SingleFileFormat_WritesToSingleFile **api.md lists all namespaces with type count**: Verifies that `api.md` contains a namespace table where every documented namespace appears with a Declarations count column so that AI agents -have a complete navigation map in a single read. This scenario is tested by +have a complete navigation map in a single read. Tested by `CppGenerator_Generate_ApiMd_ListsNamespacesWithTypeCount`. **Class operator overloads grouped on single operators page**: Verifies that all operator @@ -235,12 +235,12 @@ at `{namespace}/operators` rather than producing individual pages. Tested by **Intra-library return type emits Markdown link in table cell**: Verifies that a method whose return type is a documented intra-library type produces a Markdown hyperlink in the Returns -column of the Methods table. This scenario is tested by +column of the Methods table. Tested by `CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell`. **Gitignore-style ApiHeaderPatterns restrict documented headers**: Verifies that the generator correctly applies include, exclude, and re-include patterns to restrict the documented API surface. -This scenario is tested by `CppGenerator_Generate_NoApiHeaderPatterns_DocumentsAllHeaders`, +Tested by `CppGenerator_Generate_NoApiHeaderPatterns_DocumentsAllHeaders`, `CppGenerator_Generate_ApiHeaderPatterns_IncludePattern_OnlyMatchingFilesDocumented`, `CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles`, `CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader`, and @@ -253,11 +253,11 @@ WorkingDirectory resolution, allowing headers outside the project tree to be inc **Code example blocks rendered as fenced code**: Verifies that Doxygen `@code`/`@endcode` blocks on documented methods are rendered as fenced `cpp` code blocks on both gradual-disclosure member -pages and single-file output. This scenario is tested by +pages and single-file output. Tested by `CppGenerator_Generate_MethodWithCodeExample_EmitsCodeBlockOnMemberPage` and `CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock`. **External types section in generated output**: Verifies that when a documented type references an external (non-std, non-library) type, the generated page includes an `External Types` section with -the external type listed. This scenario is tested by +the external type listed. Tested by `CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection`. diff --git a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md index e4424b7..c90a635 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -15,7 +15,8 @@ Tests run with the standard xUnit.net test runner. - The emitter creates the api index, namespace summary, and type pages. - The emitter creates detail pages for visible members and free functions. -- The emitter creates enum pages, type-alias pages (namespace-level and class-scoped), nested-type pages, and operator pages. +- The emitter creates enum pages, type-alias pages (namespace-level and class-scoped), + nested-type pages, class-level operator pages, and namespace-level operator pages. - Case-insensitive collisions are combined onto one page. - Empty namespace collections still produce an `api.md` fallback page. - The api index page heading contains the configured library name. diff --git a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md index 4668b60..83669ed 100644 --- a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md @@ -46,24 +46,24 @@ the standard xUnit.net test runner. No clang installation is needed. **Exact qualified match emits a link**: Verifies that when a fully-qualified type name (e.g. `"ns::Foo"`) exactly matches a key in `knownTypes`, `Linkify` returns a string containing a Markdown link with the short type name and the resolved relative path. -This scenario is tested by `CppTypeLinkResolver_Linkify_ExactQualifiedMatch_EmitsLink`. +Tested by `CppTypeLinkResolver_Linkify_ExactQualifiedMatch_EmitsLink`. **Unambiguous short name emits a link**: Verifies that when only one known type has the unqualified name being resolved (e.g. `"Bar"` with only `"ns::Bar"` in `knownTypes`), `Linkify` returns a Markdown link via the short-name fallback path. -This scenario is tested by `CppTypeLinkResolver_Linkify_UnambiguousShortName_EmitsLink`. +Tested by `CppTypeLinkResolver_Linkify_UnambiguousShortName_EmitsLink`. **Ambiguous short name emits plain text**: Verifies that when two known types share the same unqualified name (e.g. `"size_type"` is both `"ns::Outer::size_type"` and `"ns::Other::size_type"`), an unqualified reference produces plain text and no link, preventing non-deterministic navigation. -This scenario is tested by `CppTypeLinkResolver_Linkify_AmbiguousShortName_EmitsPlainText`. +Tested by `CppTypeLinkResolver_Linkify_AmbiguousShortName_EmitsPlainText`. **Qualified reference to ambiguous type emits correct link**: Verifies that a fully-qualified reference (e.g. `"ns::Outer::size_type"`) still resolves to the correct page even when two types share the same unqualified name, because the exact-match path takes precedence over the ambiguous short-name path. -This scenario is tested by +Tested by `CppTypeLinkResolver_Linkify_QualifiedReferenceToAmbiguousType_EmitsCorrectLink`. **Primitive type returns unchanged**: Verifies that primitive C++ types such as `int` are @@ -92,3 +92,11 @@ reference/pointer qualifiers are removed before the base name is looked up. Test intra-library type (e.g. `Foo`) and a prefix of a template argument (e.g. `FooBar`), only the actual type token is linked and the template argument is left unchanged. Tested by `CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplateArg_EmitsLinkWithoutCorruption`. + +**Constructor rejects null knownTypes**: Verifies that passing `null` as the `knownTypes` +argument to the constructor throws `ArgumentNullException` immediately. Tested by +`CppTypeLinkResolver_Constructor_NullKnownTypes_ThrowsArgumentNullException`. + +**Linkify rejects null externalTypes**: Verifies that passing `null` as the `externalTypes` +argument to `Linkify` throws `ArgumentNullException`. Tested by +`CppTypeLinkResolver_Linkify_NullExternalTypes_ThrowsArgumentNullException`. diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs index 838edb6..30a88f8 100644 --- a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -233,6 +233,7 @@ public void CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplat [Fact] public void CppTypeLinkResolver_Constructor_NullKnownTypes_ThrowsArgumentNullException() { + // Arrange / Act / Assert Assert.Throws(() => new CppTypeLinkResolver(null!)); } @@ -240,7 +241,10 @@ public void CppTypeLinkResolver_Constructor_NullKnownTypes_ThrowsArgumentNullExc [Fact] public void CppTypeLinkResolver_Linkify_NullExternalTypes_ThrowsArgumentNullException() { + // Arrange var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + + // Act / Assert Assert.Throws(() => resolver.Linkify("SomeType", string.Empty, null!)); } } From a697544795363c16f0008fd1508053f28e30100c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 15:01:17 -0400 Subject: [PATCH 14/62] fix: Normalize all split-line 'This scenario is tested by' in api-mark-cpp.md 8 instances spanned line breaks ('This scenario istested by' or 'This scenario is testedby') that weren't caught by the single-line replace in R8/R9. Fixed with pattern matching across the line boundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/verification/api-mark-cpp.md | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index 344f908..c93d037 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -141,20 +141,17 @@ receives its own type page following the same `{namespace}/{typeName}` conventio Tested by `CppGenerator_Generate_ValidHeaders_CreatesEnumPage`. **Enum page contains all declared values**: Verifies that the enum type page includes all declared -enum value names so the complete enumeration is visible in the generated reference. This scenario -is tested by `CppGenerator_Generate_EnumPage_ContainsValues`. +enum value names so the complete enumeration is visible in the generated reference. Tested by `CppGenerator_Generate_EnumPage_ContainsValues`. **Template class creates a type page**: Verifies that a primary class template receives its own type page, confirming that template declarations are handled by the ownership filter and type page emission path. Tested by `CppGenerator_Generate_TemplateClass_CreatesTypePage`. **Inheritance class creates a type page**: Verifies that a class that inherits from another class -receives its own type page, confirming that derived types are documented independently. This -scenario is tested by `CppGenerator_Generate_InheritanceClass_CreatesTypePage`. +receives its own type page, confirming that derived types are documented independently. Tested by `CppGenerator_Generate_InheritanceClass_CreatesTypePage`. **Constructor creates a constructor detail page**: Verifies that an explicit constructor receives -its own member detail page, confirming that constructors are treated as documented members. This -scenario is tested by `CppGenerator_Generate_Constructor_CreatesConstructorPage`. +its own member detail page, confirming that constructors are treated as documented members. Tested by `CppGenerator_Generate_Constructor_CreatesConstructorPage`. **Type page contains fully qualified C++ name**: Verifies that the type page signature block contains the fully qualified C++ name (e.g. `fixtures::SampleClass`) so an AI reader knows @@ -183,18 +180,15 @@ than an unrelated null-reference failure during I/O. Tested by **Generate throws when include root does not exist**: Verifies that Generate throws `DirectoryNotFoundException` when a configured PublicIncludeRoot path does not exist on disk, -providing a clear diagnostic rather than silently producing empty output. This scenario is tested -by `CppGenerator_Generate_NonexistentIncludeRoot_ThrowsDirectoryNotFoundException`. +providing a clear diagnostic rather than silently producing empty output. Tested by `CppGenerator_Generate_NonexistentIncludeRoot_ThrowsDirectoryNotFoundException`. **Deleted copy constructor signature contains = delete suffix**: Verifies that a copy constructor declared with `= delete` is documented with a `= delete` suffix in its signature block so that -readers can see the intentional prohibition without opening the header file. This scenario is -tested by `CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix`. +readers can see the intentional prohibition without opening the header file. Tested by `CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix`. **Deleted copy-assignment operator signature contains = delete suffix**: Verifies that a copy-assignment operator declared with `= delete` is documented with a `= delete` suffix in its -signature so that the prohibition is visible on the combined operators page. This scenario is -tested by `CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix`. +signature so that the prohibition is visible on the combined operators page. Tested by `CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix`. **Type aliases receive their own pages**: Verifies that `using` type alias declarations in documented namespaces produce individual pages at `{namespace}/{aliasName}`, following the same @@ -213,8 +207,7 @@ individual alias pages. Tested by **Single-file format writes all namespaces to one api.md file**: Verifies that when the `--format single-file` option is specified, all documentation is written to a single `api.md` -file using a flat heading hierarchy rather than producing separate namespace and type pages. This -scenario is tested by `CppGenerator_Generate_SingleFileFormat_WritesToSingleFile`. +file using a flat heading hierarchy rather than producing separate namespace and type pages. Tested by `CppGenerator_Generate_SingleFileFormat_WritesToSingleFile`. **api.md lists all namespaces with type count**: Verifies that `api.md` contains a namespace table where every documented namespace appears with a Declarations count column so that AI agents @@ -223,8 +216,7 @@ have a complete navigation map in a single read. Tested by **Class operator overloads grouped on single operators page**: Verifies that all operator overloads for a class are combined onto a single `operators.md` page at -`{namespace}/{TypeName}/operators` and that the owning type page links to it. This scenario is -tested by `CppGenerator_Generate_ClassWithOperators_CreatesOperatorsPage`, +`{namespace}/{TypeName}/operators` and that the owning type page links to it. Tested by `CppGenerator_Generate_ClassWithOperators_CreatesOperatorsPage`, `CppGenerator_Generate_ClassWithOperators_OperatorsPageContainsOperatorEntry`, and `CppGenerator_Generate_ClassWithOperators_TypePageLinksToOperatorsPage`. From aadf9877e196a0eed25c59f9ab15537557bb9b65 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 15:17:45 -0400 Subject: [PATCH 15/62] fix: Batch 3 Cpp reviews Design R9 fixes - CppEmitter design doc: add instance fields (_options, _namespaceDecls, _cppResolver) to Data Model section and constructor to Key Methods - CppTypeLinkResolver design doc: remove CppEmitterSingleFile from Callers (confirmed by code: resolver is accepted but discarded via _ = cppResolver) - api-mark-cpp.md: remove CppEmitterSingleFile->CppTypeLinkResolver diagram edge (SingleFile does not use the resolver; type links omitted in single-file mode to prevent anchor collisions) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp/cpp-emitter.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/design/api-mark-cpp/cpp-emitter.md b/docs/design/api-mark-cpp/cpp-emitter.md index d798c5a..99c05eb 100644 --- a/docs/design/api-mark-cpp/cpp-emitter.md +++ b/docs/design/api-mark-cpp/cpp-emitter.md @@ -45,8 +45,18 @@ by `WriteFunctionContent`. - `ExternalTypes`: `ISet` - `ParametersHeadingLevel`: `int` +**CppEmitter instance fields** (private): state carried from construction through dispatch. + +- `_options`: `CppGeneratorOptions` — generator configuration; exposed to sub-emitters via the `internal Options` property. +- `_namespaceDecls`: `SortedDictionary` — sorted map of namespace key → declarations; passed directly to the format-specific sub-emitter constructor. +- `_cppResolver`: `CppTypeLinkResolver` — type link resolver; forwarded to the sub-emitter constructor. + ### Key Methods +#### Constructor + +**CppEmitter(options, namespaceDecls, cppResolver)** — stores all three arguments as private fields. Preconditions: none of the parameters are null (null-guard not enforced by the constructor itself; the caller `CppGenerator.Parse` always supplies non-null values). + #### Dispatch - **CppEmitter.Emit**: `public void Emit(IMarkdownWriterFactory factory, EmitConfig From 1de05d0395fb8b410c1ebc0d233d9a11a756c785 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 15:22:55 -0400 Subject: [PATCH 16/62] feat: Render type aliases in single-file output - Add WriteSingleFileTypeAliasSection to CppEmitterSingleFile.cs - Wire namespace-level TypeAliases loop in EmitSingleFile - Emit class-scoped type aliases as H{depth+3} sub-entries in WriteSingleFileClassSection - Add unit test CppEmitterSingleFile_Emit_TypeAlias_ContainsTypeAliasSection - Update design doc: Purpose, Emit algorithm, WriteSingleFileClassSection description, add WriteSingleFileTypeAliasSection Key Method entry, update Dependencies - Update api-mark-cpp.md Data Flow step 4 to include type aliases in single-file path - Add ApiMarkCpp-CppEmitterSingleFile-EmitsTypeAliasSection requirement and test link - Add type alias test scenario to verification doc Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 3 +- .../api-mark-cpp/cpp-emitter-single-file.md | 34 ++++++++----- .../api-mark-cpp/cpp-emitter-single-file.yaml | 9 ++++ .../api-mark-cpp/cpp-emitter-single-file.md | 7 ++- src/ApiMark.Cpp/CppEmitterSingleFile.cs | 48 +++++++++++++++++++ .../CppEmitterSingleFileTests.cs | 35 ++++++++++++++ 6 files changed, 122 insertions(+), 14 deletions(-) diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index 95457f7..9855789 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -125,7 +125,8 @@ N/A - not a safety-classified software item. per-nested-type, and per-class-scoped-alias pages. `SingleFile` uses `CppEmitterSingleFile`, which writes the entire API reference into a single `api.md` file with library-name and namespace headings followed by - type and member sections. + type, free-function, enum, and type-alias sections (all at H{depth+2}) and + individual member and class-scoped type-alias sub-entries at H{depth+3}. 5. During emission, the `Visibility` access-specifier filter (Public/PublicAndProtected/All) is applied by `CppEmitter` helper methods to include or exclude class members based on their C++ access specifier. Regular members receive per-member pages (gradual-disclosure); diff --git a/docs/design/api-mark-cpp/cpp-emitter-single-file.md b/docs/design/api-mark-cpp/cpp-emitter-single-file.md index fc36915..9f2f331 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/design/api-mark-cpp/cpp-emitter-single-file.md @@ -5,13 +5,14 @@ ### Purpose -CppEmitterSingleFile writes all C++ API documentation into a single `api.md` -file. All content is organized using heading levels offset by -`EmitConfig.HeadingDepth`: H{depth} for the library title, H{depth+1} for each -namespace, H{depth+2} for each type or free function, and H{depth+3} for -individual members. Type links are omitted in single-file mode to prevent anchor -collisions when all members share a single file. The path convention appendix is -also omitted because it only applies to the multi-file layout. +CppEmitterSingleFile writes all C++ API documentation — classes, free functions, +enums, and type aliases — into a single `api.md` file. All content is organized +using heading levels offset by `EmitConfig.HeadingDepth`: H{depth} for the library +title, H{depth+1} for each namespace, H{depth+2} for each type, free function, enum, +or type alias, and H{depth+3} for individual members and class-scoped type aliases. +Type links are omitted in single-file mode to prevent anchor collisions when all +members share a single file. The path convention appendix is also omitted because it +only applies to the multi-file layout. ### Data Model @@ -31,7 +32,7 @@ single `api.md` file. via `factory.CreateMarkdown("", "api")`, writes the H{depth} library-name heading in the form `{LibraryName} API Reference`, optional description, and iterates over namespaces calling `WriteSingleFileClassSection`, `WriteSingleFileFreeFunctionSection`, - and `WriteSingleFileEnumSection`. + `WriteSingleFileEnumSection`, and `WriteSingleFileTypeAliasSection`. **WriteSingleFileClassSection** (private): Emits an H{depth+2} section for a class. @@ -39,8 +40,8 @@ class. - Writes the class name heading, optional parent-context note for nested types, signature block (when a source location is available), summary, details, note, example, a compact member bullet list, H{depth+3} sections for each visible - member via `WriteSingleFileMemberSection`, and peer H{depth+2} sections for - nested classes. + member via `WriteSingleFileMemberSection`, H{depth+3} sub-entries for each + class-scoped type alias, and peer H{depth+2} sections for nested classes. **WriteSingleFileFreeFunctionSection** (private static): Emits an H{depth+2} section for a free function, including parameter types in the heading, @@ -49,6 +50,15 @@ a fenced-code signature block, summary, details, and a parameters table. **WriteSingleFileEnumSection** (private static): Emits an H{depth+2} section for a C++ enum, including summary and an enum values table. +**WriteSingleFileTypeAliasSection** (private static): Emits an H{depth+2} section +for a namespace-level C++ type alias. + +- *Algorithm*: Writes the alias name as an H{depth+2} heading, a fenced `cpp` + code block containing the qualified-name comment and `using {name} = {underlying};` + declaration, and a summary paragraph (or the no-description placeholder when no + doc comment is present). Mirrors the content produced by `WriteTypeAliasPage` in + gradual-disclosure mode. + **WriteSingleFileMemberSection** (private static): Emits an H{depth+3} section for a single class member (constructor, method, or field), including a fenced-code signature block, summary, parameters table (when applicable), Returns line (for @@ -84,8 +94,8 @@ to create the single `api.md` writer via `CreateMarkdown("", "api")`. - **CppEmitter** — parent emitter providing options, visibility helpers, comment extractors, signature builders, and `GetIncludePath`. -- **CppAstModel** — consumes `CppClass`, `CppFunction`, `CppField`, `CppEnum` - record types. +- **CppAstModel** — consumes `CppClass`, `CppFunction`, `CppField`, `CppEnum`, + and `CppTypeAlias` record types. - **IMarkdownWriterFactory** (ApiMarkCore) — supplies the single Markdown writer. ### Callers diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml index bcaec62..cb21395 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml @@ -21,6 +21,7 @@ sections: - ApiMarkCpp-CppEmitterSingleFile-EmitsFreeFunctionSection - ApiMarkCpp-CppEmitterSingleFile-EmitsEnumSection - ApiMarkCpp-CppEmitterSingleFile-EmitsMemberSection + - ApiMarkCpp-CppEmitterSingleFile-EmitsTypeAliasSection tests: - CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter - id: ApiMarkCpp-CppEmitterSingleFile-CreatesSingleApiWriter @@ -71,3 +72,11 @@ sections: Member sections preserve drill-down detail while keeping the document linear. tests: - CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsTypeAliasSection + title: CppEmitterSingleFile shall emit a section for each documented namespace-level type alias. + justification: | + Type alias pages in gradual disclosure correspond to explicit type alias sections + in the single-file layout. Without rendering type aliases the single-file output + would be incomplete and omit documented API surface. + tests: + - CppEmitterSingleFile_Emit_TypeAlias_ContainsTypeAliasSection diff --git a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md index e53b495..93cec49 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md @@ -15,7 +15,7 @@ Tests run with the standard xUnit.net test runner. - `CppEmitterSingleFile.Emit` creates exactly one writer keyed as `("", "api")`. - The api file contains the library-name heading and namespace headings. -- The api file emits class, free-function, and enum sections when present. +- The api file emits class, free-function, enum, and type-alias sections when present. - Member headings respect non-default heading-depth offsets. ### Test Scenarios @@ -30,6 +30,11 @@ single-file output. Tested by **Emit enum section**: Verifies that enum declarations are rendered into the single-file output. Tested by `CppEmitterSingleFile_Emit_Enum_ContainsEnumSection`. +**Emit type alias section**: Verifies that namespace-level type alias declarations are +rendered into the single-file output with an H3 heading containing the alias name and a +fenced code block containing the `using` declaration and underlying type. Tested by +`CppEmitterSingleFile_Emit_TypeAlias_ContainsTypeAliasSection`. + **Heading depth offset**: Verifies that all heading levels — library, namespace, class, and member — shift when a non-default heading depth is configured. Tested by `CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings`. diff --git a/src/ApiMark.Cpp/CppEmitterSingleFile.cs b/src/ApiMark.Cpp/CppEmitterSingleFile.cs index 25ba427..cf3f459 100644 --- a/src/ApiMark.Cpp/CppEmitterSingleFile.cs +++ b/src/ApiMark.Cpp/CppEmitterSingleFile.cs @@ -105,6 +105,12 @@ private void EmitSingleFile(IMarkdownWriterFactory factory, EmitConfig config) { WriteSingleFileEnumSection(writer, depth, en); } + + // Emit each namespace-level type alias as an H{depth+2} section + foreach (var alias in nsDecls.TypeAliases.OrderBy(a => a.Name, StringComparer.Ordinal)) + { + WriteSingleFileTypeAliasSection(writer, depth, nsDisplay, alias); + } } } @@ -202,6 +208,18 @@ private void WriteSingleFileClassSection( } } + // Emit class-scoped type aliases as H{depth+3} sub-entries below the member sections + var classScope = string.IsNullOrEmpty(nsDisplay) ? cls.Name : $"{nsDisplay}::{cls.Name}"; + foreach (var alias in cls.TypeAliases.OrderBy(a => a.Name, StringComparer.Ordinal)) + { + writer.WriteHeading(depth + 3, alias.Name); + var aliasQualifiedName = $"{classScope}::{alias.Name}"; + var simplifiedUnderlying = CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName); + writer.WriteSignature("cpp", $"// {aliasQualifiedName}\nusing {alias.Name} = {simplifiedUnderlying};"); + var aliasSummary = CppEmitter.GetSummary(alias.Doc); + writer.WriteParagraph(!string.IsNullOrEmpty(aliasSummary) ? aliasSummary : CppEmitter.NoDescriptionPlaceholder); + } + // Emit nested classes as peer H{depth+2} sections with a parent-context note foreach (var nested in cls.NestedClasses.OrderBy(n => n.Name, StringComparer.Ordinal)) { @@ -271,6 +289,36 @@ private static void WriteSingleFileEnumSection(IMarkdownWriter writer, int depth } } + /// + /// Emits an H{depth+2} section for a single namespace-level C++ type alias. + /// + /// Markdown writer to write to. + /// Heading depth offset from the library-level heading. + /// Display name of the enclosing namespace. + /// The type alias to emit. + /// + /// Mirrors the content produced by WriteTypeAliasPage in gradual-disclosure mode: + /// a heading, fenced cpp code block with the using declaration, and a + /// summary paragraph (or the no-description placeholder when no doc comment is present). + /// + private static void WriteSingleFileTypeAliasSection( + IMarkdownWriter writer, + int depth, + string nsDisplay, + CppTypeAlias alias) + { + writer.WriteHeading(depth + 2, alias.Name); + + // using declaration as a fenced code block — mirrors the gradual-disclosure alias page + var qualifiedName = string.IsNullOrEmpty(nsDisplay) ? alias.Name : $"{nsDisplay}::{alias.Name}"; + var simplifiedUnderlying = CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName); + writer.WriteSignature("cpp", $"// {qualifiedName}\nusing {alias.Name} = {simplifiedUnderlying};"); + + // Emit summary from doc comment or placeholder + var summary = CppEmitter.GetSummary(alias.Doc); + writer.WriteParagraph(!string.IsNullOrEmpty(summary) ? summary : CppEmitter.NoDescriptionPlaceholder); + } + /// /// Emits an H{depth+3} section for a single class member (constructor, method, or field). /// diff --git a/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs index 47d5819..cedc7a0 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs @@ -161,6 +161,41 @@ public void CppEmitterSingleFile_Emit_Enum_ContainsEnumSection() Assert.Contains(headings, h => h.Level == 3 && h.Text == "Color"); } + /// Validates that namespace-level type aliases are emitted as H3 sections in single-file output. + [Fact] + public void CppEmitterSingleFile_Emit_TypeAlias_ContainsTypeAliasSection() + { + // Arrange: build a namespace with a single type alias + var factory = new InMemoryMarkdownWriterFactory(); + var options = new CppGeneratorOptions + { + LibraryName = "TestLib", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + }; + var nsDecls = new SortedDictionary(StringComparer.Ordinal); + var ns = new CppEmitter.NamespaceDeclarations("testlib", null); + ns.TypeAliases.Add(new CppTypeAlias( + "WidgetHandle", + "unsigned int", + false, + null, + new CppDocComment("Handle to a Widget.", null, [], null))); + nsDecls["testlib"] = ns; + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var emitter = new CppEmitter(options, nsDecls, resolver); + + // Act + new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: alias name appears as an H3 heading and underlying type appears in the writer content + var writer = factory.GetWriter("", "api"); + var headings = writer.Operations.OfType().ToList(); + Assert.Contains(headings, h => h.Level == 3 && h.Text == "WidgetHandle"); + var signatures = writer.Operations.OfType().ToList(); + Assert.Contains(signatures, s => s.Code.Contains("WidgetHandle", StringComparison.Ordinal) + && s.Code.Contains("unsigned int", StringComparison.Ordinal)); + } + /// Validates that non-default heading depth offsets are applied consistently. [Fact] public void CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings() From 80abc424c1d6243ceeb493519a0977fef49df8a4 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 15:47:59 -0400 Subject: [PATCH 17/62] fix: Batch 3 Cpp reviews R10/R11 fixes - Add class-scoped type alias req + test (AllRequirements/SingleFile R10 F-01/F-02) - Fix CppEmitterSingleFile Data Model: document _emitter/_namespaceDecls fields (Design R10 F-01) - Fix CppEmitterGradualDisclosure Data Model: document instance fields (Design R10 F-02) - Add constructors to Key Methods for SingleFile and GD units (Design R10 F-03) - Extend EmitSingleFile algorithm description with namespace heading/summary (Design R10 F-04) - Add system-level type alias criterion + scenario to api-mark-cpp.md (Architecture R10) - Add CWD-relative criterion to system verification (Verification R10 VER-02) - Add final keyword criterion to system verification (Verification R10 VER-06) - Add single-file type alias criterion to system verification (Architecture R10) - Add 6 missing system-level test scenarios to api-mark-cpp.md (Verification R10 VER-01..VER-05) - Add class-scoped alias acceptance criterion to cpp-emitter-single-file.md (VER-08) - Add cross-references to mono-cecil.md and clang.md from ots.md (VER-07) - Normalize 'This scenario is tested by' in ots/clang.md (OBS-02) - Add CppGenerator_SingleFile_TypeAlias_AppearsInOutput integration test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cpp-emitter-gradual-disclosure.md | 13 ++++++ .../api-mark-cpp/cpp-emitter-single-file.md | 23 ++++++++--- .../api-mark-cpp/cpp-emitter-single-file.yaml | 10 ++++- docs/verification/api-mark-cpp.md | 41 +++++++++++++++++++ .../api-mark-cpp/cpp-emitter-single-file.md | 8 +++- docs/verification/ots.md | 4 ++ docs/verification/ots/clang.md | 11 +++-- .../CppEmitterSingleFileTests.cs | 41 +++++++++++++++++++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 16 ++++++++ 9 files changed, 153 insertions(+), 14 deletions(-) diff --git a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md index 0193dd4..f6efee6 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -27,8 +27,21 @@ and dedicated pages for enums, type aliases, and grouped operator overloads. | Operators (class) | `{Namespace}/{TypeName}/operators.md` | | Operators (namespace) | `{Namespace}/operators.md` | +**CppEmitterGradualDisclosure instance fields** (private): state supplied at construction. + +- `_emitter`: `CppEmitter` — parent emitter providing options, visibility helpers, + comment extractors, and signature builders. +- `_namespaceDecls`: `SortedDictionary` — + sorted map of namespace key → declarations passed in from `CppEmitter`. +- `_cppResolver`: `CppTypeLinkResolver` — type link resolver; used to linkify type + strings in table cells and to track external type references per page. + ### Key Methods +**CppEmitterGradualDisclosure(emitter, namespaceDecls, cppResolver)** (internal constructor): +stores all three arguments as private fields. None of the parameters are null-guarded +in the constructor; the caller `CppEmitter.Emit` always supplies non-null values. + - **Emit / EmitGradualDisclosure**: `public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context)` — writes `api.md`, then iterates namespaces, types, nested types, free functions, enums, aliases, and operator groups. diff --git a/docs/design/api-mark-cpp/cpp-emitter-single-file.md b/docs/design/api-mark-cpp/cpp-emitter-single-file.md index 9f2f331..e42fe2f 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/design/api-mark-cpp/cpp-emitter-single-file.md @@ -16,12 +16,20 @@ only applies to the multi-file layout. ### Data Model -N/A - CppEmitterSingleFile holds no data model of its own. It reads from the -`SortedDictionary` supplied by -`CppEmitter` and from `CppGeneratorOptions` via the parent emitter. +**CppEmitterSingleFile instance fields** (private): state supplied at construction. + +- `_emitter`: `CppEmitter` — parent emitter providing options, visibility helpers, + comment extractors, and signature builders. +- `_namespaceDecls`: `SortedDictionary` — + sorted map of namespace key → declarations passed in from `CppEmitter`. ### Key Methods +**CppEmitterSingleFile(emitter, namespaceDecls, cppResolver)** (internal constructor): +stores `emitter` and `namespaceDecls` as private fields. `cppResolver` is accepted to +satisfy the uniform constructor contract but is deliberately discarded (`_ = cppResolver`) +because type links are omitted in single-file mode to prevent anchor collisions. + **CppEmitterSingleFile.Emit** (internal): Entry point; writes all content into a single `api.md` file. @@ -30,9 +38,12 @@ single `api.md` file. - *Returns*: `void` - *Algorithm*: Calls `EmitSingleFile(factory, config)` which creates one writer via `factory.CreateMarkdown("", "api")`, writes the H{depth} library-name heading - in the form `{LibraryName} API Reference`, optional description, and iterates over - namespaces calling `WriteSingleFileClassSection`, `WriteSingleFileFreeFunctionSection`, - `WriteSingleFileEnumSection`, and `WriteSingleFileTypeAliasSection`. + in the form `{LibraryName} API Reference`, optional description paragraph, and + iterates over namespaces. Within each namespace iteration, writes an H{depth+1} + namespace heading (`nsDecls.DisplayName`) and an optional namespace summary + paragraph, then calls `WriteSingleFileClassSection`, + `WriteSingleFileFreeFunctionSection`, `WriteSingleFileEnumSection`, and + `WriteSingleFileTypeAliasSection`. **WriteSingleFileClassSection** (private): Emits an H{depth+2} section for a class. diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml index cb21395..83b42dd 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml @@ -22,6 +22,7 @@ sections: - ApiMarkCpp-CppEmitterSingleFile-EmitsEnumSection - ApiMarkCpp-CppEmitterSingleFile-EmitsMemberSection - ApiMarkCpp-CppEmitterSingleFile-EmitsTypeAliasSection + - ApiMarkCpp-CppEmitterSingleFile-EmitsClassScopedTypeAliasSection tests: - CppEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter - id: ApiMarkCpp-CppEmitterSingleFile-CreatesSingleApiWriter @@ -72,7 +73,14 @@ sections: Member sections preserve drill-down detail while keeping the document linear. tests: - CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings - - id: ApiMarkCpp-CppEmitterSingleFile-EmitsTypeAliasSection + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsClassScopedTypeAliasSection + title: CppEmitterSingleFile shall emit class-scoped type alias sub-entries below the owning class section at H{depth+3}. + justification: | + Class-scoped type aliases are part of the class API surface. Without rendering + them in single-file output, AI agents would have no visibility of class-level + using declarations such as size_type. + tests: + - CppEmitterSingleFile_Emit_ClassScopedTypeAlias_ContainsAliasSubEntry title: CppEmitterSingleFile shall emit a section for each documented namespace-level type alias. justification: | Type alias pages in gradual disclosure correspond to explicit type alias sections diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index c93d037..865db8b 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -57,6 +57,11 @@ configuration beyond a standard clang installation is required. rendered as Markdown hyperlinks to the corresponding type page. - Absolute `ApiHeaderPatterns` entries select headers by their full path without WorkingDirectory resolution. +- CWD-relative `ApiHeaderPatterns` entries are resolved against `WorkingDirectory` before + pattern matching, allowing project-relative patterns to restrict the documented API surface. +- Classes declared `final` include the `final` keyword in the generated class signature block. +- When the single-file format is specified, type aliases (both namespace-level and class-scoped) + appear in the generated output. ## Test Scenarios @@ -253,3 +258,39 @@ pages and single-file output. Tested by external (non-std, non-library) type, the generated page includes an `External Types` section with the external type listed. Tested by `CppGenerator_Generate_ExternalTypeReference_EmitsExternalTypesSection`. + +**Type alias appears in single-file output**: Verifies that namespace-level `using` type alias +declarations appear as headings in the single-file `api.md` output, confirming that the single-file +emitter emits type alias sections alongside class, enum, and function sections. Tested by +`CppGenerator_SingleFile_TypeAlias_AppearsInOutput`. + +**Case-insensitive member collision combines onto one page**: Verifies that when two class members +have names that differ only in letter case (a case-insensitive collision), the generator combines +them onto a single shared member page rather than creating conflicting file names. Tested by +`CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage`, +`CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers`, and +`CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage`. + +**CWD-relative ApiHeaderPatterns restrict documented headers**: Verifies that +`ApiHeaderPatterns` entries that are relative paths are resolved against `WorkingDirectory`, +allowing project-relative include and exclude patterns to restrict the documented API surface. +Tested by +`CppGenerator_Generate_ApiHeaderPatterns_CwdRelativePattern_OnlyMatchingFilesDocumented` and +`CppGenerator_Generate_ApiHeaderPatterns_CwdRelativeExclusionPattern_ExcludesMatchingFiles`. + +**Parse throws when context is null**: Verifies that passing a null context to `CppGenerator.Parse` +throws `ArgumentNullException` immediately, so misconfigured callers fail fast. Tested by +`CppGenerator_Parse_NullContext_ThrowsArgumentNullException`. + +**Class-scoped type aliases receive their own pages and are listed on the owning class page**: +Verifies that `using` type alias declarations scoped to a class produce individual pages at +`{namespace}/{TypeName}/{AliasName}.md` and are listed in the "Type Aliases" section of the +owning class page. Tested by +`CppGenerator_Generate_ClassScopedTypeAlias_CreatesAliasPage` and +`CppGenerator_Generate_ClassScopedTypeAlias_ListedOnClassPage`. + +**Transitively included headers do not contribute symbols to output**: Verifies that symbols +declared in headers that are only transitively included (not directly matched by +`ApiHeaderPatterns`) are excluded from the generated documentation, preventing unintended API +surface expansion. Tested by +`CppGenerator_Generate_ApiHeaderPatterns_TransitiveInclude_ExcludesNonSelectedSymbols`. diff --git a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md index 93cec49..675ed28 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-single-file.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-single-file.md @@ -15,7 +15,8 @@ Tests run with the standard xUnit.net test runner. - `CppEmitterSingleFile.Emit` creates exactly one writer keyed as `("", "api")`. - The api file contains the library-name heading and namespace headings. -- The api file emits class, free-function, enum, and type-alias sections when present. +- The api file emits class, free-function, enum, and namespace-level type-alias sections when present. +- Class-scoped type aliases appear as H{depth+3} sub-entries within their owning class section. - Member headings respect non-default heading-depth offsets. ### Test Scenarios @@ -35,6 +36,11 @@ rendered into the single-file output with an H3 heading containing the alias nam fenced code block containing the `using` declaration and underlying type. Tested by `CppEmitterSingleFile_Emit_TypeAlias_ContainsTypeAliasSection`. +**Emit class-scoped type alias sub-entry**: Verifies that a class-scoped `using` type alias +declaration appears as an H{depth+3} sub-entry below the owning class section, with a +signature block containing the `using` declaration and underlying type. Tested by +`CppEmitterSingleFile_Emit_ClassScopedTypeAlias_ContainsAliasSubEntry`. + **Heading depth offset**: Verifies that all heading levels — library, namespace, class, and member — shift when a non-default heading depth is configured. Tested by `CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings`. diff --git a/docs/verification/ots.md b/docs/verification/ots.md index 7d4c229..4d65724 100644 --- a/docs/verification/ots.md +++ b/docs/verification/ots.md @@ -51,6 +51,8 @@ Any change in discovered members, rendered signatures, or generated file layout the fixture assembly baseline is treated as a regression candidate and must be reviewed before the upgrade is accepted. +See `docs/verification/ots/mono-cecil.md` for detailed test scenarios. + ## clang ApiMark verifies the clang integration by testing the exact externally supplied behavior that @@ -71,6 +73,8 @@ integration tests against the same fixture headers used for baseline qualificati discovered types, rendered signatures, doc comment availability, or generated file layout is treated as a regression candidate and must be reviewed before the version change is accepted. +See `docs/verification/ots/clang.md` for detailed test scenarios. + ## ANTLR4 The ANTLR4 runtime (`Antlr4.Runtime.Standard`) is verified indirectly through `VhdlAstParser` unit diff --git a/docs/verification/ots/clang.md b/docs/verification/ots/clang.md index a97afd3..7a2b423 100644 --- a/docs/verification/ots/clang.md +++ b/docs/verification/ots/clang.md @@ -14,27 +14,26 @@ generated documentation behavior against representative fixture headers. **Header files parse and produce an API entrypoint**: Verifies that clang can be discovered, invoked with the required flags, and that the resulting JSON AST is parsed successfully to produce -the top-level `api.md` entrypoint file. This scenario is tested by +the top-level `api.md` entrypoint file. Tested by `CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint`. **Declaration provenance identifies public API declarations**: Verifies that the source file path exposed by clang in the JSON AST is sufficient for ClangAstParser to distinguish declarations -defined in the public include roots from system and third-party declarations. This scenario is -tested by `CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass`. +defined in the public include roots from system and third-party declarations. Tested by `CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass`. **Type and member metadata is complete enough for documentation generation**: Verifies that clang exposes the names, types, parameters, and access specifiers needed for ClangAstParser to produce -accurate type pages, member detail pages, and function signatures. These scenarios are tested by +accurate type pages, member detail pages, and function signatures. Tested by `CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass`, `CppGenerator_Generate_ValidHeaders_CreatesEnumPage`, and `CppGenerator_Generate_TemplateClass_CreatesTypePage`. **Doxygen doc comments are accessible on parsed declarations**: Verifies that structured Doxygen comment data is available on parsed declarations so ClangAstParser can render `@brief` descriptions -as paragraphs in the output. This scenario is tested by +as paragraphs in the output. Tested by `CppGenerator_Generate_TypeWithDocComment_WritesSummaryToParagraph`. **Parse options are forwarded to clang**: Verifies that include paths and parse options provided through CppGeneratorOptions are accepted and applied during clang invocation so headers that depend -on other include directories resolve correctly. This scenario is tested by +on other include directories resolve correctly. Tested by `CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint`. diff --git a/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs index cedc7a0..f3374a2 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterSingleFileTests.cs @@ -196,6 +196,47 @@ public void CppEmitterSingleFile_Emit_TypeAlias_ContainsTypeAliasSection() && s.Code.Contains("unsigned int", StringComparison.Ordinal)); } + /// Validates that class-scoped type aliases appear as sub-entries below the owning class. + [Fact] + public void CppEmitterSingleFile_Emit_ClassScopedTypeAlias_ContainsAliasSubEntry() + { + // Arrange: build a class with a class-scoped type alias + var factory = new InMemoryMarkdownWriterFactory(); + var options = new CppGeneratorOptions + { + LibraryName = "TestLib", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + }; + var nsDecls = new SortedDictionary(StringComparer.Ordinal); + var ns = new CppEmitter.NamespaceDeclarations("testlib", null); + ns.Classes.Add(new CppClass( + "Widget", + [], + [], + [], + [], + [], + [new CppTypeAlias("size_type", "unsigned int", false, null, new CppDocComment("Size type.", null, [], null))], + false, + false, + null, + new CppDocComment("A widget.", null, [], null))); + nsDecls["testlib"] = ns; + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var emitter = new CppEmitter(options, nsDecls, resolver); + + // Act + new CppEmitterSingleFile(emitter, nsDecls, resolver).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: class-scoped alias appears as an H4 heading (depth+3 with default depth=1) with a signature + var writer = factory.GetWriter("", "api"); + var headings = writer.Operations.OfType().ToList(); + Assert.Contains(headings, h => h.Level == 4 && h.Text == "size_type"); + var signatures = writer.Operations.OfType().ToList(); + Assert.Contains(signatures, s => s.Code.Contains("size_type", StringComparison.Ordinal) + && s.Code.Contains("unsigned int", StringComparison.Ordinal)); + } + /// Validates that non-default heading depth offsets are applied consistently. [Fact] public void CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings() diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 562b2a4..745885c 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1556,6 +1556,22 @@ public void CppGenerator_SingleFile_MethodWithCodeExample_EmitsCodeBlock() cb.Code.Contains("GetGreeting", StringComparison.Ordinal)); } + /// + /// Validates that namespace-level using type alias declarations appear as headings + /// in the single-file api.md output, confirming the single-file emitter emits + /// type alias sections alongside class and enum sections. + /// + [Fact] + public void CppGenerator_SingleFile_TypeAlias_AppearsInOutput() + { + // Act: locate the single api.md writer and find all headings + var writer = _fixture.PublicSingleFileFactory.Writers["api"]; + var headings = writer.Operations.OfType().ToList(); + + // Assert: the namespace-level alias item_id_t appears as a heading in the output + Assert.Contains(headings, h => h.Text.Contains("item_id_t", StringComparison.Ordinal)); + } + /// /// Validates that an absolute entry /// selects the file it names via its absolute path, confirming that absolute patterns are From 8822c515b73256a7fa187e2e16084bc1009f8215 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 15:57:34 -0400 Subject: [PATCH 18/62] fix: Correct duplicate YAML keys in cpp-emitter-single-file.yaml (AllRequirements R11) Split malformed EmitsClassScopedTypeAliasSection block into two proper entries: - EmitsTypeAliasSection (namespace-level aliases) - EmitsClassScopedTypeAliasSection (class-scoped aliases at H{depth+3}) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-mark-cpp/cpp-emitter-single-file.yaml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml index 83b42dd..0b6a98f 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-single-file.yaml @@ -73,6 +73,14 @@ sections: Member sections preserve drill-down detail while keeping the document linear. tests: - CppEmitterSingleFile_Emit_NonDefaultHeadingDepth_OffsetsHeadings + - id: ApiMarkCpp-CppEmitterSingleFile-EmitsTypeAliasSection + title: CppEmitterSingleFile shall emit a section for each documented namespace-level type alias. + justification: | + Type alias pages in gradual disclosure correspond to explicit type alias sections + in the single-file layout. Without rendering type aliases the single-file output + would be incomplete and omit documented API surface. + tests: + - CppEmitterSingleFile_Emit_TypeAlias_ContainsTypeAliasSection - id: ApiMarkCpp-CppEmitterSingleFile-EmitsClassScopedTypeAliasSection title: CppEmitterSingleFile shall emit class-scoped type alias sub-entries below the owning class section at H{depth+3}. justification: | @@ -81,10 +89,3 @@ sections: using declarations such as size_type. tests: - CppEmitterSingleFile_Emit_ClassScopedTypeAlias_ContainsAliasSubEntry - title: CppEmitterSingleFile shall emit a section for each documented namespace-level type alias. - justification: | - Type alias pages in gradual disclosure correspond to explicit type alias sections - in the single-file layout. Without rendering type aliases the single-file output - would be incomplete and omit documented API surface. - tests: - - CppEmitterSingleFile_Emit_TypeAlias_ContainsTypeAliasSection From 36fc2bd7d52c0a5875262f7420be8012b1381f80 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 16:01:06 -0400 Subject: [PATCH 19/62] fix: Batch 3 Cpp reviews R11 doc fixes - Add CppTypeLinkResolver constructor to Key Methods (Design R11) - Add final keyword test scenario to system verification (Architecture R11) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp/cpp-type-link-resolver.md | 7 +++++++ docs/verification/api-mark-cpp.md | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/docs/design/api-mark-cpp/cpp-type-link-resolver.md b/docs/design/api-mark-cpp/cpp-type-link-resolver.md index d7d0c39..72a9392 100644 --- a/docs/design/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -28,6 +28,13 @@ fundamental C++ type names that must always remain plain text. ### Key Methods +**CppTypeLinkResolver(knownTypes)** (internal constructor): stores `knownTypes` as the +private `_knownTypes` field. + +- *Parameter*: `knownTypes: IReadOnlyDictionary` — mapping from fully + qualified C++ name to documentation page key. +- *Preconditions*: `knownTypes` must not be null; throws `ArgumentNullException` if null. + - **Linkify** — returns the original string unchanged for null/whitespace input, primitives, and `std::` types; also returns the original value unchanged when qualifier stripping yields an empty string (degenerate input); resolves exact diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index 865db8b..2b5aee8 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -264,6 +264,10 @@ declarations appear as headings in the single-file `api.md` output, confirming t emitter emits type alias sections alongside class, enum, and function sections. Tested by `CppGenerator_SingleFile_TypeAlias_AppearsInOutput`. +**Class declared `final` includes `final` keyword in signature**: Verifies that a class +declared with the `final` specifier has the `final` keyword included in its generated class +signature block. Tested by `CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature`. + **Case-insensitive member collision combines onto one page**: Verifies that when two class members have names that differ only in letter case (a case-insensitive collision), the generator combines them onto a single shared member page rather than creating conflicting file names. Tested by From 3183ae17e92096b12935d5a538273aa106eb0623 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 16:10:02 -0400 Subject: [PATCH 20/62] fix: Batch 3 Cpp reviews R12 fixes - Fix stale test name in api-mark-cpp.md system verification (Verification R12) - Split compound GD EmitTypeAliasPage into namespace-level and class-scoped reqs (AllRequirements R12) - Add CppEmitterGradualDisclosure_Emit_ClassScopedTypeAlias_CreatesAliasPage test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cpp-emitter-gradual-disclosure.yaml | 20 +++++++++++++------ docs/verification/api-mark-cpp.md | 2 +- .../CppEmitterGradualDisclosureTests.cs | 15 ++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml index 600d724..b843fa8 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml @@ -22,7 +22,8 @@ sections: - ApiMarkCpp-CppEmitterGradualDisclosure-EmitMemberDetailPage - ApiMarkCpp-CppEmitterGradualDisclosure-EmitFreeFunctionPage - ApiMarkCpp-CppEmitterGradualDisclosure-EmitEnumPage - - ApiMarkCpp-CppEmitterGradualDisclosure-EmitTypeAliasPage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceLevelTypeAliasPage + - ApiMarkCpp-CppEmitterGradualDisclosure-EmitClassScopedTypeAliasPage - ApiMarkCpp-CppEmitterGradualDisclosure-EmitClassOperatorsPage - ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceOperatorsPage tests: @@ -79,14 +80,21 @@ sections: inflating namespace pages. tests: - CppEmitterGradualDisclosure_Emit_Enum_CreatesEnumPage - - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitTypeAliasPage - title: >- - CppEmitterGradualDisclosure shall produce detail pages for namespace-level - and class-scoped type aliases. + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitNamespaceLevelTypeAliasPage + title: CppEmitterGradualDisclosure shall produce a detail page for each namespace-level type alias. justification: | - Public aliases are first-class API declarations and require stable paths. + Public namespace-level aliases are first-class API declarations and require + stable paths in the multi-file layout. tests: - CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage + - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitClassScopedTypeAliasPage + title: CppEmitterGradualDisclosure shall produce a detail page for each class-scoped type alias under the owning class folder. + justification: | + Class-scoped aliases are part of the type's API surface and must receive + their own pages at {namespace}/{TypeName}/{AliasName} so they are + individually navigable. + tests: + - CppEmitterGradualDisclosure_Emit_ClassScopedTypeAlias_CreatesAliasPage - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitClassOperatorsPage title: >- CppEmitterGradualDisclosure shall group class-scoped operators onto a diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index 2b5aee8..cf5ed8c 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -212,7 +212,7 @@ individual alias pages. Tested by **Single-file format writes all namespaces to one api.md file**: Verifies that when the `--format single-file` option is specified, all documentation is written to a single `api.md` -file using a flat heading hierarchy rather than producing separate namespace and type pages. Tested by `CppGenerator_Generate_SingleFileFormat_WritesToSingleFile`. +file using a flat heading hierarchy rather than producing separate namespace and type pages. Tested by `CppGenerator_Generate_SingleFileOutput_WritesSingleApiMarkdown`. **api.md lists all namespaces with type count**: Verifies that `api.md` contains a namespace table where every documented namespace appears with a Declarations count column so that AI agents diff --git a/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs index 97c2517..d99c181 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs @@ -216,6 +216,21 @@ public void CppEmitterGradualDisclosure_Emit_TypeAlias_CreatesTypeAliasPage() Assert.True(factory.HasWriter("testlib/Widget", "size_type")); } + /// Validates that class-scoped type aliases receive their own detail pages under the owning class folder. + [Fact] + public void CppEmitterGradualDisclosure_Emit_ClassScopedTypeAlias_CreatesAliasPage() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var (emitter, nsDecls, resolver) = BuildRichData(); + + // Act + new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: class-scoped alias page at {namespace}/{TypeName}/{AliasName} + Assert.True(factory.HasWriter("testlib/Widget", "size_type")); + } + /// Validates that nested classes receive pages under their parent class folder. [Fact] public void CppEmitterGradualDisclosure_Emit_NestedClass_CreatesNestedClassPage() From 3b95bb5b2edd87916ab9be7a18fa20b98f7fac66 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 16:16:45 -0400 Subject: [PATCH 21/62] fix: Broaden DocumentTypeAliases scope to include class-scoped aliases (AllRequirements R13) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/api-mark-cpp/cpp-generator.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index 491946c..24ec39a 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -266,8 +266,8 @@ sections: - CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix - id: ApiMarkCpp-CppGenerator-DocumentTypeAliases title: >- - CppGenerator shall document using type aliases declared in documented - namespaces + CppGenerator shall document using type aliases declared in the + documented API surface (namespaces and classes) as first-class entries — each alias receives its own page showing the using declaration, the underlying type, and the Doxygen summary comment. From 174f880753882da2ab944f982936ea03ce0338f4 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 17:03:54 -0400 Subject: [PATCH 22/62] fix: Batch 4 Vhdl R1 review fixes --- docs/design/api-mark-vhdl.md | 4 + docs/design/api-mark-vhdl/vhdl-ast-parser.md | 3 + .../vhdl-emitter-gradual-disclosure.md | 9 +- .../api-mark-vhdl/vhdl-emitter-single-file.md | 15 +- docs/design/api-mark-vhdl/vhdl-emitter.md | 5 + docs/design/api-mark-vhdl/vhdl-generator.md | 4 +- .../api-mark-vhdl/vhdl-ast-model.yaml | 5 + .../api-mark-vhdl/vhdl-ast-parser.yaml | 15 +- .../vhdl-emitter-gradual-disclosure.yaml | 16 ++ .../reqstream/api-mark-vhdl/vhdl-emitter.yaml | 31 +++- .../api-mark-vhdl/vhdl-generator.yaml | 17 +- docs/verification/api-mark-vhdl.md | 28 +++- .../api-mark-vhdl/vhdl-ast-parser.md | 6 + .../vhdl-emitter-gradual-disclosure.md | 9 ++ .../api-mark-vhdl/vhdl-emitter-single-file.md | 77 +++++++++ .../api-mark-vhdl/vhdl-emitter.md | 24 ++- .../api-mark-vhdl/vhdl-generator.md | 34 +++- src/ApiMark.Vhdl/VhdlAst/VhdlAstModel.cs | 4 +- src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs | 6 +- src/ApiMark.Vhdl/VhdlEmitter.cs | 5 +- .../VhdlEmitterGradualDisclosure.cs | 4 +- src/ApiMark.Vhdl/VhdlEmitterSingleFile.cs | 2 +- src/ApiMark.Vhdl/VhdlGenerator.cs | 1 + test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs | 149 ++++++++++-------- test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs | 57 ++++++- test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs | 46 ++++++ 26 files changed, 469 insertions(+), 107 deletions(-) diff --git a/docs/design/api-mark-vhdl.md b/docs/design/api-mark-vhdl.md index 1bda9d3..1c42356 100644 --- a/docs/design/api-mark-vhdl.md +++ b/docs/design/api-mark-vhdl.md @@ -101,6 +101,10 @@ N/A - not a safety-classified software item. `VhdlEntityDecl`, `VhdlArchitectureDecl`, and `VhdlPackageDecl` records. 4. `VhdlAstParser` returns a `VhdlFileModel` containing all declarations found in the file, with associated doc comments extracted from --! annotations. + When the file contains syntax errors, `VhdlAstParser.Parse` throws + `InvalidOperationException`; `VhdlGenerator` catches the exception per file, + emits an error message via `context.WriteError`, and continues parsing the + remaining files, so a single malformed file does not abort the entire run. 5. VhdlGenerator collects all VhdlFileModel results and constructs a `VhdlEmitter` wrapping the options and file models. 6. When Emit is called, VhdlEmitter dispatches to `VhdlEmitterGradualDisclosure` diff --git a/docs/design/api-mark-vhdl/vhdl-ast-parser.md b/docs/design/api-mark-vhdl/vhdl-ast-parser.md index 6e8b08f..29f3a03 100644 --- a/docs/design/api-mark-vhdl/vhdl-ast-parser.md +++ b/docs/design/api-mark-vhdl/vhdl-ast-parser.md @@ -150,6 +150,9 @@ listener that accumulates syntax errors and throws rather than writing to a `VhdlDocComment`. +**VhdlAstParser.ParseDocCommentLines** (private static): Parses a list of `--!` comment +lines into a `VhdlDocComment`, recognising `@brief`, `@param`, and `@return` tags. + - `@brief ` → `Summary` - `@param ` → `VhdlParamDoc` entry in `Params` - `@return ` → `Returns` diff --git a/docs/design/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md b/docs/design/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md index 2997b8f..7223048 100644 --- a/docs/design/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md @@ -57,10 +57,11 @@ entity detail page. - H1 entity name, `*Entity declared in \`{fileName}\`*` attribution paragraph, summary, details, Generics section (H2 — table when generics are present, - `NoItemsPlaceholder` paragraph when empty), Ports table - (Name/Direction/Type/Description), Architectures section (inline — one bold - entry per architecture formatted as `**{name}** (\`{fileName}\`): {summary}` - with optional details paragraph). + `NoItemsPlaceholder` paragraph when empty), optional Ports table + (H2 Name/Direction/Type/Description — section omitted when the entity has no ports), + optional Architectures section (H2 — section omitted when no architectures implement + the entity; when present, one bold entry per architecture formatted as + `**{name}** (\`{fileName}\`): {summary}` with optional details paragraph). **VhdlEmitterGradualDisclosure.EmitPackagePage** (private static): Writes a single package detail page and calls `EmitSubprogramDetailPage` for each subprogram. diff --git a/docs/design/api-mark-vhdl/vhdl-emitter-single-file.md b/docs/design/api-mark-vhdl/vhdl-emitter-single-file.md index 067844a..31e06ae 100644 --- a/docs/design/api-mark-vhdl/vhdl-emitter-single-file.md +++ b/docs/design/api-mark-vhdl/vhdl-emitter-single-file.md @@ -17,10 +17,14 @@ file using heading levels offset by `EmitConfig.HeadingDepth`. **Output file layout** (single `api.md`): -- H{depth} library name. -- H{depth+1} Entities section — one H{depth+2} per entity, with H{depth+3} - Generics, Ports, and Architectures sub-sections. -- H{depth+1} Packages section — one H{depth+2} per package, with: +- H{depth} {library name} API Reference. +- H{depth+1} Entities section — written only when at least one entity is present; + one H{depth+2} per entity, with H{depth+3} Generics (always written; uses + `NoItemsPlaceholder` when empty), optional H{depth+3} Ports (omitted when the + entity has no ports), and optional H{depth+3} Architectures sub-sections (omitted + when no architectures implement the entity). +- H{depth+1} Packages section — written only when at least one package is present; + one H{depth+2} per package, with: - H{depth+3} Types — one paragraph per type declaration. - H{depth+3} Constants — one paragraph per constant declaration. - H{depth+3} Components — one paragraph per component declaration. @@ -88,7 +92,8 @@ per-subprogram block within the single-file output. `NoItemsPlaceholder`). - **IMarkdownWriterFactory** (ApiMarkCore) — used to create the single Markdown writer. - **VhdlAstModel** (internal) — consumes `VhdlFileModel`, `VhdlEntityDecl`, - `VhdlArchitectureDecl`, and `VhdlPackageDecl` record types. + `VhdlArchitectureDecl`, `VhdlPackageDecl`, `VhdlSubprogramDecl`, and + `VhdlParamDecl` record types. ### Callers diff --git a/docs/design/api-mark-vhdl/vhdl-emitter.md b/docs/design/api-mark-vhdl/vhdl-emitter.md index 7069da3..0bf6532 100644 --- a/docs/design/api-mark-vhdl/vhdl-emitter.md +++ b/docs/design/api-mark-vhdl/vhdl-emitter.md @@ -20,6 +20,11 @@ format-specific emitters. shared by both format-specific emitters. - `NoDescriptionPlaceholder`: `internal const string` — `"*No description provided.*"`; cell text used when no summary is available. +- `NoItemsPlaceholder`: `internal const string` — `"*None.*"`; placeholder text used + when a section has no items. +- `ObjectClassKeywords`: `private static readonly HashSet` — set of VHDL + object-class keywords (`SIGNAL`, `VARIABLE`, `CONSTANT`, `FILE`) stripped from + parameter modes by `FormatParamType`. - `Options`: `VhdlGeneratorOptions` property (internal get) — exposes `_options` to format-specific emitters. diff --git a/docs/design/api-mark-vhdl/vhdl-generator.md b/docs/design/api-mark-vhdl/vhdl-generator.md index 09d8ee0..46a96a7 100644 --- a/docs/design/api-mark-vhdl/vhdl-generator.md +++ b/docs/design/api-mark-vhdl/vhdl-generator.md @@ -15,13 +15,15 @@ configured glob patterns to enumerate VHDL source files, delegates parsing to **VhdlGeneratorOptions** (public): Configuration record supplied by the caller. - `LibraryName`: `string` — the name of the VHDL library to document; must be non-empty. +- `Description`: `string` — introductory paragraph text emitted at the top of the api index + page (gradual disclosure) or below the top-level heading (single-file). An empty string + produces no description paragraph. - `Sources`: `IList` — glob patterns that identify source files to include. Patterns prefixed with `!` are exclusion patterns. Evaluated with gitignore-style last-match-wins semantics by `GlobFileCollector` from ApiMarkCore. An empty list produces no matched files. - `WorkingDirectory`: `string?` — the base directory for glob evaluation. When `null`, defaults to `Directory.GetCurrentDirectory()`. -- Additional display and format options are forwarded to `VhdlEmitter` unchanged. ### Key Methods diff --git a/docs/reqstream/api-mark-vhdl/vhdl-ast-model.yaml b/docs/reqstream/api-mark-vhdl/vhdl-ast-model.yaml index 6c7f9c9..39e332f 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-ast-model.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-ast-model.yaml @@ -17,3 +17,8 @@ sections: - VhdlAstParser_Parse_FixtureFile_ReturnsEntity - VhdlAstParser_Parse_FixtureFile_EntityHasGenerics - VhdlAstParser_Parse_FixtureFile_EntityHasPorts + - VhdlAstParser_Parse_CommonTypesFixture_ReturnsPackage + - VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoTypes + - VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoConstants + - VhdlAstParser_Parse_CommonTypesFixture_PackageHasOneComponent + - VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoSubprograms diff --git a/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml b/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml index 46610fc..4571d6f 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml @@ -6,7 +6,8 @@ sections: - title: VhdlAstParser Requirements requirements: - id: ApiMarkVhdl-VhdlAstParser-ParseEntity - title: VhdlAstParser shall parse entity declarations from a .vhd file. + title: VhdlAstParser shall parse entity declarations including generics, ports, + and associated doc comments from a .vhd file. justification: | Entity declarations are the primary design unit in VHDL. tests: @@ -16,7 +17,8 @@ sections: - VhdlAstParser_Parse_FixtureFile_EntityDocCommentParsed - VhdlAstParser_Parse_FixtureFile_PortsHaveInlineDocComments - id: ApiMarkVhdl-VhdlAstParser-ParseArchitecture - title: VhdlAstParser shall parse architecture bodies from a .vhd file. + title: VhdlAstParser shall parse architecture bodies from a .vhd file, capturing + the architecture name and associated entity name. justification: | A file may contain multiple architectures for the same entity. tests: @@ -42,3 +44,12 @@ sections: - VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasNullReturnType - VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasParamEntry - VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasReturnEntry + - id: ApiMarkVhdl-VhdlAstParser-RejectInvalidSyntax + title: VhdlAstParser shall throw InvalidOperationException when the source file + contains syntax errors that prevent the ANTLR4 parser from producing a valid + parse tree. + justification: | + Failing fast on syntax errors prevents silently emitting corrupt or empty + documentation from a file that could not be correctly parsed. + tests: + - VhdlAstParser_Parse_InvalidVhdl_ThrowsInvalidOperationException diff --git a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml index 1d15e90..642d8a2 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml @@ -11,7 +11,23 @@ sections: pages. justification: | Separate files per declaration enable gradual disclosure navigation. + children: + - ApiMarkVhdl-VhdlEmitterGradualDisclosure-EmitSubprogramDetailPage tests: - VhdlEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage - VhdlEmitterGradualDisclosure_Emit_MinimalData_CreatesEntityPage - VhdlEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibraryNameHeading + - id: ApiMarkVhdl-VhdlEmitterGradualDisclosure-EmitSubprogramDetailPage + title: VhdlEmitterGradualDisclosure shall write one detail file per subprogram + declared in a package, placed under a per-package subfolder + ({packageName}/{subprogramName}.md). + justification: | + Per-subprogram detail files enable fine-grained gradual disclosure so an + AI can navigate from the package index to individual subprogram signatures + without loading the entire package page. + tests: + - VhdlEmitterGradualDisclosure_Emit_CreatesSubprogramDetailFile + - VhdlEmitterGradualDisclosure_Emit_SubprogramDetailFileHasSignatureHeading + - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprogram_CreatesSubprogramDetailPage + - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprogram_SubprogramPageContainsSignatureHeading + - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprogram_SubprogramPageContainsSummary diff --git a/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml b/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml index 698f330..323e307 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml @@ -5,9 +5,30 @@ sections: sections: - title: VhdlEmitter Requirements requirements: - - id: ApiMarkVhdl-VhdlEmitter - title: VhdlEmitter shall implement IApiEmitter and dispatch to format-specific - emitters. + - id: ApiMarkVhdl-VhdlEmitter-ImplementsIApiEmitter + title: VhdlEmitter shall implement the IApiEmitter interface. justification: | - The dispatcher separates format selection from emission logic. - tests: [VhdlEmitter_Emit_NullFactory_ThrowsArgumentNullException] + Implementing IApiEmitter allows VhdlEmitter to be returned from + VhdlGenerator.Parse and consumed by ApiMarkTool through the standard + two-stage pipeline. + children: + - ApiMarkVhdl-VhdlEmitter-DispatchesFormat + - ApiMarkVhdl-VhdlEmitter-ValidatesNullFactory + tests: [] + - id: ApiMarkVhdl-VhdlEmitter-DispatchesFormat + title: VhdlEmitter shall dispatch to VhdlEmitterGradualDisclosure or VhdlEmitterSingleFile + based on config.Format. + justification: | + The dispatcher separates format selection from emission logic, allowing + each format-specific emitter to be developed and tested independently. + tests: + - VhdlEmitter_Emit_GradualDisclosureFormat_ProducesMultipleOutputFiles + - VhdlEmitter_Emit_SingleFileFormat_ProducesSingleOutputFile + - VhdlEmitter_Emit_EmptyFileModels_ProducesNoOutput + - id: ApiMarkVhdl-VhdlEmitter-ValidatesNullFactory + title: VhdlEmitter shall throw ArgumentNullException when the factory argument is null. + justification: | + Failing fast on a null factory provides a clear error before any writer + or pipeline logic is invoked. + tests: + - VhdlEmitter_Emit_NullFactory_ThrowsArgumentNullException diff --git a/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml b/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml index f653687..633128c 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml @@ -7,13 +7,28 @@ sections: requirements: - id: ApiMarkVhdl-VhdlGenerator-AcceptSourceGlobPatterns title: VhdlGenerator shall accept one or more glob patterns that select - which .vhd source files to document. + which .vhd source files to document, using gitignore-style last-match-wins + semantics with ! prefix exclusion patterns. justification: | VHDL projects organize IP cores as individual files or as directories of .vhd files. Glob patterns with gitignore-style last-match-wins semantics and ! prefix exclusion allow callers to specify and refine the source file set without requiring project reorganization. + children: + - ApiMarkVhdl-VhdlGenerator-ReportNoFilesMatched tests: - VhdlGenerator_Constructor_NullOptions_ThrowsArgumentNullException + - VhdlGenerator_Constructor_EmptyLibraryName_ThrowsArgumentException + - VhdlGenerator_Constructor_WhitespaceLibraryName_ThrowsArgumentException - VhdlGenerator_Generate_FixtureFile_CreatesApiEntrypoint - VhdlGenerator_Generate_FixtureFile_CreatesEntityPage + - VhdlGenerator_Generate_AllFixtures_ProducesExpectedOutputStructure + - id: ApiMarkVhdl-VhdlGenerator-ReportNoFilesMatched + title: VhdlGenerator shall report an error via the context when no source + files match the configured glob patterns. + justification: | + Reporting a clear error when no files are matched prevents silently + producing an empty documentation set that would indicate successful + generation to the caller. + tests: + - VhdlGenerator_Parse_NoFilesMatched_EmitsErrorAndReturnsEmptyEmitter diff --git a/docs/verification/api-mark-vhdl.md b/docs/verification/api-mark-vhdl.md index e960112..b0dcfca 100644 --- a/docs/verification/api-mark-vhdl.md +++ b/docs/verification/api-mark-vhdl.md @@ -3,12 +3,15 @@ ## Verification Approach ApiMarkVhdl is verified with unit tests in `test/ApiMark.Vhdl.Tests/` that exercise -the VHDL generation pipeline using a synthetic VHDL fixture file with --! doc comments -located in `test/ApiMark.Vhdl.Tests/Fixtures/`. The ANTLR4 vhdl2008 parser is used -as-is so verification proves the interaction between VHDL parsing, doc comment -extraction, and Markdown emission. Emitter unit tests use in-memory data (no file -I/O) to verify output structure without invoking the parser. Fixture files are -located in the source tree via `[CallerFilePath]` resolution in `FixturePaths`. +the VHDL generation pipeline using three VHDL fixture files with --! doc comments +located in `test/ApiMark.Vhdl.Tests/Fixtures/`: `counter.vhd` (entity with generics, +ports, and inline doc comments), `mux.vhd` (entity with two architecture bodies), and +`common_types.vhd` (package with types, constants, components, and subprograms). The +ANTLR4 vhdl2008 parser is used as-is so verification proves the interaction between +VHDL parsing, doc comment extraction, and Markdown emission. Emitter unit tests use +in-memory data (no file I/O) to verify output structure without invoking the parser. +Fixture files are located in the source tree via `[CallerFilePath]` resolution in +`FixturePaths`. ## Test Environment @@ -18,9 +21,12 @@ additional toolchain dependency is required — the ANTLR4 runtime is a NuGet pa ## Acceptance Criteria - All ApiMarkVhdl tests pass with zero failures. -- The parser correctly extracts entity names, generics, and ports from the fixture file. +- The parser correctly extracts entity names, generics, and ports from the counter fixture file. - Preceding --! block comments are associated with entity declarations. - Inline --! trailing comments are associated with port and generic declarations. +- The parser correctly extracts both architecture bodies from the mux fixture file. +- The parser correctly extracts a package declaration including types, constants, + components, and subprograms from the common_types fixture file. - The gradual-disclosure emitter creates an api index page and at least one entity page. - The single-file emitter creates exactly one file. @@ -44,6 +50,14 @@ counter entity is extracted and the Summary field is populated. Tested by trailing comment parsed into a VhdlDocComment. Tested by `VhdlAstParser_Parse_FixtureFile_PortsHaveInlineDocComments`. +**Mux fixture parses two architecture bodies**: Verifies that parsing `mux.vhd` returns +exactly two `VhdlArchitectureDecl` records. Tested by +`VhdlAstParser_Parse_MuxFixture_ParsesTwoArchitectures`. + +**Package is returned from common_types fixture**: Verifies that parsing `common_types.vhd` +returns a `VhdlFileModel` with at least one package declaration. Tested by +`VhdlAstParser_Parse_CommonTypesFixture_ReturnsPackage`. + **Constructor null options throws**: Verifies that `VhdlGenerator(null)` throws `ArgumentNullException`. Tested by `VhdlGenerator_Constructor_NullOptions_ThrowsArgumentNullException`. diff --git a/docs/verification/api-mark-vhdl/vhdl-ast-parser.md b/docs/verification/api-mark-vhdl/vhdl-ast-parser.md index 69c130a..a54f09c 100644 --- a/docs/verification/api-mark-vhdl/vhdl-ast-parser.md +++ b/docs/verification/api-mark-vhdl/vhdl-ast-parser.md @@ -110,3 +110,9 @@ This scenario is tested by `VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocH **Doc @return entry extracted**: Verifies that the doc comment on `to_natural` has a non-null `Returns` field matching the `@return` tag in the source. This scenario is tested by `VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasReturnEntry`. + +**Invalid VHDL throws InvalidOperationException**: Verifies that calling `VhdlAstParser.Parse` +on a file containing deliberately invalid VHDL syntax throws `InvalidOperationException`, +confirming that syntax errors are detected and surfaced rather than silently producing corrupt +output. +This scenario is tested by `VhdlAstParser_Parse_InvalidVhdl_ThrowsInvalidOperationException`. diff --git a/docs/verification/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md b/docs/verification/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md index 999ec85..f9f9b53 100644 --- a/docs/verification/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md @@ -19,6 +19,8 @@ configuration are required. - `VhdlEmitterGradualDisclosure.Emit` produces at least one entity detail page in addition to the api index page. - The api index page content includes the library name as a heading. +- When an entity has architectures that implement it, the entity detail page includes an + Architectures section rendered inline (not as separate files). ### Test Scenarios @@ -37,3 +39,10 @@ emitter contains the library name as a heading, confirming that library metadata correctly. This scenario is tested by `VhdlEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibraryNameHeading`. + +**Architecture rendered inline on entity page**: Verifies that when an entity has associated +architectures, those architectures appear as an inline section on the entity detail page rather +than as separate files, confirming that the gradual-disclosure emitter does not create +standalone architecture pages. +This scenario is tested by +`VhdlEmitterGradualDisclosure_Emit_EntityWithArchitecture_ArchitectureSectionAppearsInEntityPage`. diff --git a/docs/verification/api-mark-vhdl/vhdl-emitter-single-file.md b/docs/verification/api-mark-vhdl/vhdl-emitter-single-file.md index 4aa4c21..8cd2f90 100644 --- a/docs/verification/api-mark-vhdl/vhdl-emitter-single-file.md +++ b/docs/verification/api-mark-vhdl/vhdl-emitter-single-file.md @@ -18,6 +18,9 @@ configuration are required. - `VhdlEmitterSingleFile.Emit` creates exactly one writer in the writer factory output, confirming that all content is consolidated into a single Markdown file. - The single output file is keyed as `"api"`, confirming it is the expected api file. +- Both entities and packages appear in the single api file output. +- Architecture bodies are rendered inline within their entity section. +- Subprogram sections include a Signature heading and an optional Parameters table. ### Test Scenarios @@ -29,3 +32,77 @@ This scenario is tested by `VhdlEmitterSingleFile_Emit_MinimalData_CreatesExactl **Creates api file only**: Verifies that the single writer created is keyed as `"api"`, confirming that the output file name matches the expected api entrypoint. This scenario is tested by `VhdlEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly`. + +**Two entities both appear in output**: Verifies that when two entities are present, both +entity headings appear in the single api file. +This scenario is tested by `VhdlEmitterSingleFile_Emit_TwoEntities_BothAppearInOutput`. + +**Package with types emits Types section**: Verifies that a package containing type +declarations produces a Types heading and paragraph-per-type format in the api file. +This scenario is tested by `VhdlEmitterSingleFile_Emit_PackageWithTypes_EmitsTypesSection`. + +**Package with subprograms has no kind attribution paragraph**: Verifies that the subprogram +section does not contain a standalone italic kind paragraph, as the kind is visible from the +Signature. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_PackageWithSubprograms_NoKindAttributionParagraph`. + +**Package with subprograms contains Signature heading**: Verifies that the subprogram section +contains a Signature heading for the fenced VHDL code block. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_PackageWithSubprograms_SubprogramSectionContainsSignatureHeading`. + +**Entity with architecture produces Architectures section**: Verifies that an entity with an +associated architecture body produces an Architectures sub-section in the api file. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_EntityWithArchitecture_ArchitectureSectionAppearsInOutput`. + +**Architecture paragraph contains filename**: Verifies that the architecture entry paragraph +contains both the bold architecture name and the source filename in backticks. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_EntityWithArchitecture_ArchitectureParagraphContainsFilename`. + +**Entity with no generics emits Generics heading**: Verifies that the Generics heading is +always written even when an entity has no generic declarations. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_EntityWithNoGenerics_EmitsGenericsHeading`. + +**Entity with no generics emits none-placeholder**: Verifies that the Generics section +contains the `NoItemsPlaceholder` paragraph when no generics are present. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_EntityWithNoGenerics_EmitsNonePlaceholderInGenericsSection`. + +**Entity section contains attribution paragraph**: Verifies that the entity section includes +an attribution paragraph naming the source file. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_Entity_SectionContainsEntityAttributionParagraph`. + +**Package section contains attribution paragraph**: Verifies that the package section includes +an attribution paragraph naming the source file. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_Package_SectionContainsPackageAttributionParagraph`. + +**Subprogram with parameters emits Parameters heading**: Verifies that a subprogram with +formal parameters produces a Parameters heading and table. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_SubprogramWithParameters_EmitsParametersHeading`. + +**Function subprogram emits Returns heading**: Verifies that a function subprogram produces +a Returns heading. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_FunctionSubprogram_EmitsReturnsHeading`. + +**Parameters table has correct headers**: Verifies that the parameters table headers are +Name, Type, Description (no Mode column). +This scenario is tested by +`VhdlEmitterSingleFile_Emit_SubprogramWithParameters_ParametersTableHasCorrectHeaders`. + +**Plain parameter type cell is bare type name**: Verifies that a parameter with empty mode +shows only the bare type name without a direction prefix. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_SubprogramWithPlainParameter_TypeCellIsBareTypeName`. + +**Directed parameter type cell is prefixed with direction**: Verifies that a parameter with +an explicit direction has the direction keyword prepended to the type name in the Type cell. +This scenario is tested by +`VhdlEmitterSingleFile_Emit_SubprogramWithDirectedParameter_TypeCellPrefixedWithDirection`. diff --git a/docs/verification/api-mark-vhdl/vhdl-emitter.md b/docs/verification/api-mark-vhdl/vhdl-emitter.md index 98e6194..6d96f75 100644 --- a/docs/verification/api-mark-vhdl/vhdl-emitter.md +++ b/docs/verification/api-mark-vhdl/vhdl-emitter.md @@ -3,9 +3,9 @@ ### Verification Approach `VhdlEmitter` is verified with unit tests in `test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs`. -The test confirms that the emitter validates its arguments before delegating to the format-specific -implementation, and that passing a null factory produces a clear exception rather than a -deferred null-reference failure. +Tests confirm that the emitter validates its arguments, dispatches correctly to each +format-specific implementation, and returns early without output when no file models +are present. ### Test Environment @@ -16,6 +16,10 @@ configuration are required. - `VhdlEmitter.Emit` with a null factory argument throws `ArgumentNullException` immediately, before any writer or pipeline logic is invoked. +- `VhdlEmitter.Emit` with the default (`GradualDisclosure`) format and at least one file model + produces more than one output writer. +- `VhdlEmitter.Emit` with `SingleFile` format produces exactly one writer keyed `"api"`. +- `VhdlEmitter.Emit` with an empty file models list produces no output writers. ### Test Scenarios @@ -23,3 +27,17 @@ configuration are required. argument to `VhdlEmitter.Emit` throws `ArgumentNullException`, providing a clear error rather than a null-reference failure during writer creation. This scenario is tested by `VhdlEmitter_Emit_NullFactory_ThrowsArgumentNullException`. + +**GradualDisclosure format dispatches and produces multiple files**: Verifies that the default +format produces more than one output writer — the api index page and at least one entity page — +confirming that dispatch to `VhdlEmitterGradualDisclosure` is working. +This scenario is tested by `VhdlEmitter_Emit_GradualDisclosureFormat_ProducesMultipleOutputFiles`. + +**SingleFile format dispatches and produces exactly one file**: Verifies that `SingleFile` +format produces exactly one writer keyed `"api"`, confirming dispatch to +`VhdlEmitterSingleFile`. +This scenario is tested by `VhdlEmitter_Emit_SingleFileFormat_ProducesSingleOutputFile`. + +**Empty file models produces no output**: Verifies that when no file models are present, +`Emit` returns early and no writers are created by the factory. +This scenario is tested by `VhdlEmitter_Emit_EmptyFileModels_ProducesNoOutput`. diff --git a/docs/verification/api-mark-vhdl/vhdl-generator.md b/docs/verification/api-mark-vhdl/vhdl-generator.md index 73e8b5d..a177b13 100644 --- a/docs/verification/api-mark-vhdl/vhdl-generator.md +++ b/docs/verification/api-mark-vhdl/vhdl-generator.md @@ -3,9 +3,9 @@ ### Verification Approach `VhdlGenerator` is verified with unit tests in `test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs`. -Tests exercise the full VHDL generation pipeline using the `counter.vhd` fixture file and an -`InMemoryMarkdownWriterFactory` test double that captures emitted output without performing -file-system I/O. +Tests exercise the full VHDL generation pipeline using the fixture files in +`test/ApiMark.Vhdl.Tests/Fixtures/` and an `InMemoryMarkdownWriterFactory` test double that +captures emitted output without performing file-system I/O. ### Test Environment @@ -16,8 +16,13 @@ configuration are required. - `VhdlGenerator(null)` throws `ArgumentNullException` immediately, before any pipeline processing begins. -- Running `Generate` against the counter fixture file produces an `api.md` entrypoint file. -- Running `Generate` against the counter fixture file produces at least one entity detail page. +- `VhdlGenerator` with an empty or whitespace `LibraryName` throws `ArgumentException`. +- Running `Parse` with a source pattern that matches no files calls `context.WriteError` + and returns an emitter that produces no output. +- Running `Generate` against the fixture files produces an `api.md` entrypoint file. +- Running `Generate` against the fixture files produces at least one entity detail page. +- Running `Generate` against all fixture files produces the expected output structure + including entity pages, a package page, and no standalone architecture pages. ### Test Scenarios @@ -26,6 +31,20 @@ configuration are required. a deferred null-reference failure. This scenario is tested by `VhdlGenerator_Constructor_NullOptions_ThrowsArgumentNullException`. +**Constructor rejects empty LibraryName**: Verifies that constructing `VhdlGenerator` with +an empty `LibraryName` throws `ArgumentException`. +This scenario is tested by `VhdlGenerator_Constructor_EmptyLibraryName_ThrowsArgumentException`. + +**Constructor rejects whitespace LibraryName**: Verifies that constructing `VhdlGenerator` +with a whitespace-only `LibraryName` throws `ArgumentException`. +This scenario is tested by `VhdlGenerator_Constructor_WhitespaceLibraryName_ThrowsArgumentException`. + +**No files matched emits error and produces no output**: Verifies that when the source glob +pattern matches no files, `context.WriteError` is called and the returned emitter produces +no output writers. +This scenario is tested by +`VhdlGenerator_Parse_NoFilesMatched_EmitsErrorAndReturnsEmptyEmitter`. + **Generate fixture file creates api entrypoint**: Verifies that the full pipeline, when run against the counter fixture file, produces an `api.md` output file via the writer factory. This scenario is tested by `VhdlGenerator_Generate_FixtureFile_CreatesApiEntrypoint`. @@ -34,3 +53,8 @@ This scenario is tested by `VhdlGenerator_Generate_FixtureFile_CreatesApiEntrypo least one entity detail page in addition to the api index, confirming that per-entity Markdown is emitted. This scenario is tested by `VhdlGenerator_Generate_FixtureFile_CreatesEntityPage`. + +**Generate all fixtures produces expected output structure**: Verifies that running the +generator against all fixture files (counter.vhd, mux.vhd, common_types.vhd) produces +entity pages, a package page, and no standalone architecture pages. +This scenario is tested by `VhdlGenerator_Generate_AllFixtures_ProducesExpectedOutputStructure`. diff --git a/src/ApiMark.Vhdl/VhdlAst/VhdlAstModel.cs b/src/ApiMark.Vhdl/VhdlAst/VhdlAstModel.cs index 35bec1c..a4cb5f2 100644 --- a/src/ApiMark.Vhdl/VhdlAst/VhdlAstModel.cs +++ b/src/ApiMark.Vhdl/VhdlAst/VhdlAstModel.cs @@ -14,7 +14,7 @@ public record VhdlDocComment(string? Summary, string? Details, IReadOnlyListRepresents a port in a VHDL entity declaration. /// Port name. -/// Port direction: IN, OUT, INOUT, BUFFER, or in (default). +/// Port direction: one of IN, OUT, INOUT, or BUFFER (uppercase). Defaults to IN per VHDL-2008 when no explicit direction is written. /// Port type as declared in source. /// Documentation extracted from inline --! comment, or null. public record VhdlPortDoc(string Name, string Direction, string TypeName, VhdlDocComment? Doc); @@ -69,7 +69,7 @@ public enum VhdlSubprogramKind /// Represents a parameter in a VHDL subprogram declaration. /// Parameter name. -/// Parameter mode: IN, OUT, INOUT, BUFFER, SIGNAL, VARIABLE, CONSTANT, or empty. +/// Parameter mode token(s): one of the direction keywords (IN, OUT, INOUT, BUFFER), one of the object-class keywords (SIGNAL, VARIABLE, CONSTANT, FILE), a combination of both (e.g., SIGNAL IN), or an empty string when no explicit mode is specified. /// Parameter type as declared in source. public record VhdlParamDecl(string Name, string Mode, string TypeName); diff --git a/src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs b/src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs index 61585c9..8a40f13 100644 --- a/src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs +++ b/src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs @@ -17,7 +17,7 @@ internal static class VhdlAstParser internal static VhdlFileModel Parse(string filePath) { var sourceText = File.ReadAllText(filePath); - var lines = File.ReadAllLines(filePath); + var lines = sourceText.Split('\n').Select(l => l.TrimEnd('\r')).ToArray(); var input = new AntlrInputStream(sourceText); var lexer = new vhdl2008Lexer(input); @@ -750,7 +750,7 @@ private sealed class CollectingErrorListener : IAntlrErrorListener, IAntlrE { private readonly List _errors = []; - // Called by the lexer (offending symbol is an int token type) + /// Called by the ANTLR4 lexer when a syntax error is encountered; appends the error to the collected list. public void SyntaxError( TextWriter output, IRecognizer recognizer, @@ -761,7 +761,7 @@ public void SyntaxError( RecognitionException e) => _errors.Add($"line {line}:{charPositionInLine} {msg}"); - // Called by the parser (offending symbol is an IToken) + /// Called by the ANTLR4 parser when a syntax error is encountered; appends the error to the collected list. public void SyntaxError( TextWriter output, IRecognizer recognizer, diff --git a/src/ApiMark.Vhdl/VhdlEmitter.cs b/src/ApiMark.Vhdl/VhdlEmitter.cs index 9fc8062..cb7369c 100644 --- a/src/ApiMark.Vhdl/VhdlEmitter.cs +++ b/src/ApiMark.Vhdl/VhdlEmitter.cs @@ -15,11 +15,14 @@ internal sealed class VhdlEmitter : IApiEmitter /// Placeholder text for sections that have no items. internal const string NoItemsPlaceholder = "*None.*"; - /// Object-class keywords stripped from subprogram parameter types before display. + /// VHDL object-class keywords (SIGNAL, VARIABLE, CONSTANT, FILE) stripped from parameter mode strings by FormatParamType. private static readonly HashSet ObjectClassKeywords = new(StringComparer.OrdinalIgnoreCase) { "SIGNAL", "VARIABLE", "CONSTANT", "FILE" }; + /// Generator options forwarded from VhdlGenerator. private readonly VhdlGeneratorOptions _options; + + /// Parsed file models to emit. private readonly IReadOnlyList _fileModels; /// Initializes a new VhdlEmitter with the specified options and file models. diff --git a/src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs b/src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs index f024088..23bf09d 100644 --- a/src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs +++ b/src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs @@ -20,8 +20,8 @@ internal VhdlEmitterGradualDisclosure(VhdlEmitter emitter, IReadOnlyListEmits gradual-disclosure Markdown output: one file per entity/package plus an api index, with architectures rendered inline on entity pages. /// Factory for creating per-file Markdown writers. - /// Emit configuration (format and heading depth). - /// Output channel for progress and error messages. + /// Accepted for interface-signature consistency; not consumed — format selection is performed upstream by VhdlEmitter. + /// Accepted for interface-signature consistency; not consumed by this implementation. internal void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) { // Suppress unused parameter warning diff --git a/src/ApiMark.Vhdl/VhdlEmitterSingleFile.cs b/src/ApiMark.Vhdl/VhdlEmitterSingleFile.cs index 8ce64d2..a2ef24f 100644 --- a/src/ApiMark.Vhdl/VhdlEmitterSingleFile.cs +++ b/src/ApiMark.Vhdl/VhdlEmitterSingleFile.cs @@ -21,7 +21,7 @@ internal VhdlEmitterSingleFile(VhdlEmitter emitter, IReadOnlyList /// Emits all VHDL documentation into a single api.md file. /// Factory for creating Markdown writers. /// Emit configuration (format and heading depth). - /// Output channel for progress and error messages. + /// Accepted for interface-signature consistency; not consumed by this implementation. internal void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) { // Suppress unused parameter warning diff --git a/src/ApiMark.Vhdl/VhdlGenerator.cs b/src/ApiMark.Vhdl/VhdlGenerator.cs index a8c93d7..4c87a8a 100644 --- a/src/ApiMark.Vhdl/VhdlGenerator.cs +++ b/src/ApiMark.Vhdl/VhdlGenerator.cs @@ -12,6 +12,7 @@ public sealed class VhdlGenerator : IApiGenerator /// Generator options. Must not be null. LibraryName must not be null or whitespace. /// Thrown when options is null. /// Thrown when options.LibraryName is null or whitespace. + /// If options.Sources is , it is normalized to an empty list in-place before use. public VhdlGenerator(VhdlGeneratorOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs b/test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs index 0e2c3bc..ec71152 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs @@ -11,13 +11,13 @@ public class VhdlAstParserTests [Fact] public void VhdlAstParser_Parse_FixtureFile_ReturnsEntity() { - // Arrange + // Arrange: resolve path to counter.vhd fixture file var path = FixturePaths.GetFixtureFilePath("counter.vhd"); - // Act + // Act: invoke parser on the fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: result must contain at least one entity Assert.NotEmpty(model.Entities); } @@ -25,13 +25,13 @@ public void VhdlAstParser_Parse_FixtureFile_ReturnsEntity() [Fact] public void VhdlAstParser_Parse_FixtureFile_EntityHasGenerics() { - // Arrange + // Arrange: resolve path to counter.vhd fixture file var path = FixturePaths.GetFixtureFilePath("counter.vhd"); - // Act + // Act: invoke parser on the fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: single entity must have at least one generic var entity = Assert.Single(model.Entities); Assert.NotEmpty(entity.Generics); } @@ -40,13 +40,13 @@ public void VhdlAstParser_Parse_FixtureFile_EntityHasGenerics() [Fact] public void VhdlAstParser_Parse_FixtureFile_EntityHasPorts() { - // Arrange + // Arrange: resolve path to counter.vhd fixture file var path = FixturePaths.GetFixtureFilePath("counter.vhd"); - // Act + // Act: invoke parser on the fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: single entity must have at least one port var entity = Assert.Single(model.Entities); Assert.NotEmpty(entity.Ports); } @@ -55,13 +55,13 @@ public void VhdlAstParser_Parse_FixtureFile_EntityHasPorts() [Fact] public void VhdlAstParser_Parse_FixtureFile_EntityDocCommentParsed() { - // Arrange + // Arrange: resolve path to counter.vhd fixture file var path = FixturePaths.GetFixtureFilePath("counter.vhd"); - // Act + // Act: invoke parser on the fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: entity doc comment Summary field must be populated var entity = Assert.Single(model.Entities); Assert.NotNull(entity.Doc); Assert.False(string.IsNullOrEmpty(entity.Doc.Summary)); @@ -71,13 +71,13 @@ public void VhdlAstParser_Parse_FixtureFile_EntityDocCommentParsed() [Fact] public void VhdlAstParser_Parse_FixtureFile_PortsHaveInlineDocComments() { - // Arrange + // Arrange: resolve path to counter.vhd fixture file var path = FixturePaths.GetFixtureFilePath("counter.vhd"); - // Act + // Act: invoke parser on the fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: at least one port must have an inline --! trailing comment var entity = Assert.Single(model.Entities); Assert.Contains(entity.Ports, p => p.Doc != null); } @@ -86,13 +86,13 @@ public void VhdlAstParser_Parse_FixtureFile_PortsHaveInlineDocComments() [Fact] public void VhdlAstParser_Parse_MuxFixture_ParsesTwoArchitectures() { - // Arrange + // Arrange: resolve path to mux.vhd fixture file var path = FixturePaths.MuxVhd; - // Act + // Act: invoke parser on the mux fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: mux.vhd contains two architecture bodies Assert.Equal(2, model.Architectures.Count); } @@ -100,13 +100,13 @@ public void VhdlAstParser_Parse_MuxFixture_ParsesTwoArchitectures() [Fact] public void VhdlAstParser_Parse_MuxFixture_HasMuxEntity() { - // Arrange + // Arrange: resolve path to mux.vhd fixture file var path = FixturePaths.MuxVhd; - // Act + // Act: invoke parser on the mux fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: single entity name must be "mux" var entity = Assert.Single(model.Entities); Assert.Equal("mux", entity.Name); } @@ -115,13 +115,13 @@ public void VhdlAstParser_Parse_MuxFixture_HasMuxEntity() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ReturnsPackage() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: at least one package must be present Assert.NotEmpty(model.Packages); } @@ -129,13 +129,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ReturnsPackage() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoTypes() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: single package must have exactly two type declarations var pkg = Assert.Single(model.Packages); Assert.Equal(2, pkg.Types.Count); } @@ -144,13 +144,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoTypes() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoConstants() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: single package must have exactly two constant declarations var pkg = Assert.Single(model.Packages); Assert.Equal(2, pkg.Constants.Count); } @@ -159,13 +159,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoConstants() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ConstantsHaveDocComments() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: DATA_WIDTH constant must have a non-empty Summary in its doc comment var pkg = Assert.Single(model.Packages); var dataWidth = pkg.Constants.FirstOrDefault(c => c.Name == "DATA_WIDTH"); Assert.NotNull(dataWidth); @@ -177,13 +177,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ConstantsHaveDocComments() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_PackageHasOneComponent() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: single package must have exactly one component declaration var pkg = Assert.Single(model.Packages); Assert.Single(pkg.Components); } @@ -192,13 +192,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_PackageHasOneComponent() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoSubprograms() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: single package must have exactly two subprogram declarations var pkg = Assert.Single(model.Packages); Assert.Equal(2, pkg.Subprograms.Count); } @@ -207,13 +207,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoSubprograms() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalIsFunction() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: to_natural subprogram kind must be Function var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "to_natural"); Assert.NotNull(subprogram); @@ -224,13 +224,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalIsFunction() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ClearVectorIsProcedure() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: clear_vector subprogram kind must be Procedure var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "clear_vector"); Assert.NotNull(subprogram); @@ -241,13 +241,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ClearVectorIsProcedure() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalHasOneParameter() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: to_natural must have one parameter named "v" of type STD_LOGIC_VECTOR with empty mode var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "to_natural"); Assert.NotNull(subprogram); @@ -263,13 +263,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalHasOneParameter() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalHasReturnTypeNatural() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: to_natural return type must be "NATURAL" var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "to_natural"); Assert.NotNull(subprogram); @@ -280,13 +280,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalHasReturnTypeNatural [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasOneParameter() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: clear_vector must have one parameter with SIGNAL or OUT in the mode string var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "clear_vector"); Assert.NotNull(subprogram); @@ -305,13 +305,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasOneParameter() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasNullReturnType() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: procedure must have a null ReturnType var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "clear_vector"); Assert.NotNull(subprogram); @@ -322,13 +322,13 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasNullReturnType( [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasParamEntry() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: to_natural doc must contain a @param entry for "v" var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "to_natural"); Assert.NotNull(subprogram); @@ -340,17 +340,40 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasParamEntry() [Fact] public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasReturnEntry() { - // Arrange + // Arrange: resolve path to common_types.vhd fixture file var path = FixturePaths.CommonTypesVhd; - // Act + // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert + // Assert: to_natural doc must have a non-empty Returns field from @return tag var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "to_natural"); Assert.NotNull(subprogram); Assert.NotNull(subprogram.Doc); Assert.False(string.IsNullOrEmpty(subprogram.Doc.Returns)); } + + /// Validates that a file with invalid VHDL syntax throws InvalidOperationException. + [Fact] + public void VhdlAstParser_Parse_InvalidVhdl_ThrowsInvalidOperationException() + { + // Arrange: write deliberately invalid VHDL content to a temp file + var tempFile = Path.GetTempFileName() + ".vhd"; + try + { + File.WriteAllText(tempFile, "this is not valid vhdl syntax!!!"); + + // Act / Assert: parsing invalid VHDL must throw InvalidOperationException + Assert.Throws(() => VhdlAstParser.Parse(tempFile)); + } + finally + { + // Clean up temp file regardless of outcome + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } } diff --git a/test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs b/test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs index 7e54e59..1f3970f 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs @@ -12,12 +12,65 @@ public class VhdlEmitterTests [Fact] public void VhdlEmitter_Emit_NullFactory_ThrowsArgumentNullException() { - // Arrange + // Arrange: build an emitter with no file models var options = new VhdlGeneratorOptions { LibraryName = "TestLib" }; var emitter = new VhdlEmitter(options, []); - // Act / Assert + // Act / Assert: null factory must be rejected immediately Assert.Throws(() => emitter.Emit(null!, new EmitConfig(), new InMemoryContext())); } + + /// Validates that GradualDisclosure format produces more than one output writer (api.md plus at least one entity page). + [Fact] + public void VhdlEmitter_Emit_GradualDisclosureFormat_ProducesMultipleOutputFiles() + { + // Arrange: build an emitter with one entity so GD format writes api.md and an entity page + var options = new VhdlGeneratorOptions { LibraryName = "TestLib" }; + var entity = new VhdlEntityDecl("MyEntity", [], [], new VhdlDocComment("A test entity.", null, [])); + var fileModel = new VhdlFileModel("test.vhd", [entity], [], []); + var emitter = new VhdlEmitter(options, [fileModel]); + var factory = new InMemoryMarkdownWriterFactory(); + + // Act: emit with the default (GradualDisclosure) format + emitter.Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: api.md plus at least one entity page means more than one writer + Assert.True(factory.Writers.Count > 1, "Expected more than one output writer for GradualDisclosure format"); + } + + /// Validates that SingleFile format produces exactly one writer keyed "api". + [Fact] + public void VhdlEmitter_Emit_SingleFileFormat_ProducesSingleOutputFile() + { + // Arrange: build an emitter with one entity + var options = new VhdlGeneratorOptions { LibraryName = "TestLib" }; + var entity = new VhdlEntityDecl("MyEntity", [], [], new VhdlDocComment("A test entity.", null, [])); + var fileModel = new VhdlFileModel("test.vhd", [entity], [], []); + var emitter = new VhdlEmitter(options, [fileModel]); + var factory = new InMemoryMarkdownWriterFactory(); + + // Act: emit with SingleFile format + emitter.Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: exactly one writer keyed "api" + Assert.Single(factory.Writers); + Assert.True(factory.HasWriter("", "api"), "Expected the single writer to be keyed 'api'"); + } + + /// Validates that when no file models exist, Emit produces no writers. + [Fact] + public void VhdlEmitter_Emit_EmptyFileModels_ProducesNoOutput() + { + // Arrange: build an emitter with an empty file models list + var options = new VhdlGeneratorOptions { LibraryName = "TestLib" }; + var emitter = new VhdlEmitter(options, []); + var factory = new InMemoryMarkdownWriterFactory(); + + // Act: emit — empty models should return early with no output + emitter.Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: no writers created + Assert.Empty(factory.Writers); + } } diff --git a/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs b/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs index fd15fb8..10b7c88 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs @@ -15,6 +15,52 @@ public void VhdlGenerator_Constructor_NullOptions_ThrowsArgumentNullException() Assert.Throws(() => new VhdlGenerator(null!)); } + /// Validates that empty LibraryName throws ArgumentException. + [Fact] + public void VhdlGenerator_Constructor_EmptyLibraryName_ThrowsArgumentException() + { + // Arrange: options with an empty LibraryName + var options = new VhdlGeneratorOptions { LibraryName = string.Empty }; + + // Act / Assert: empty LibraryName must be rejected at construction time + Assert.Throws(() => new VhdlGenerator(options)); + } + + /// Validates that a whitespace-only LibraryName throws ArgumentException. + [Fact] + public void VhdlGenerator_Constructor_WhitespaceLibraryName_ThrowsArgumentException() + { + // Arrange: options with a whitespace-only LibraryName + var options = new VhdlGeneratorOptions { LibraryName = " " }; + + // Act / Assert: whitespace LibraryName must be rejected at construction time + Assert.Throws(() => new VhdlGenerator(options)); + } + + /// Validates that a source pattern matching no files emits an error and returns an emitter that produces no output. + [Fact] + public void VhdlGenerator_Parse_NoFilesMatched_EmitsErrorAndReturnsEmptyEmitter() + { + // Arrange: use a pattern that will not match any files + var options = new VhdlGeneratorOptions + { + LibraryName = "TestLib", + WorkingDirectory = FixturePaths.FixturesDirectory, + Sources = ["*.nonexistent"], + }; + var generator = new VhdlGenerator(options); + var factory = new InMemoryMarkdownWriterFactory(); + var context = new InMemoryContext(); + + // Act: parse with a non-matching pattern + var emitter = generator.Parse(context); + emitter.Emit(factory, new EmitConfig(), context); + + // Assert: an error message was written and no output was produced + Assert.NotEmpty(context.Errors); + Assert.Empty(factory.Writers); + } + /// Validates that the generator creates the api entrypoint file from the fixture. [Fact] public void VhdlGenerator_Generate_FixtureFile_CreatesApiEntrypoint() From 68230905d3c57ecd36ead13e7b4981cdcfd9d1ec Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 18:24:26 -0400 Subject: [PATCH 23/62] fix: correct VHDL docs, requirements, and add missing tests - Fix 1: correct 5 wrong test references in vhdl-emitter-gradual-disclosure.yaml (EmitSubprogramDetailPage requirement now links to the 4 actual test names) - Fix 2: correct arch test name in vhdl-emitter-gradual-disclosure.md verification doc and add subprogram detail page scenarios - Fix 3a: correct VhdlEmitterGradualDisclosure architecture description in api-mark-vhdl.md (architectures are inline, not separate files) - Fix 3b: remove version-specific platform constraint from api-mark-vhdl.md - Fix 3c: add ANTLR4 OTS cross-reference link in api-mark-vhdl.md dependencies - Fix 4: remove orphaned fragment 'a VhdlDocComment.' from vhdl-ast-parser.md - Fix 5: add VhdlEmitter_ImplementsIApiEmitter test to VhdlEmitterTests.cs and link it in vhdl-emitter.yaml - Fix 6: update VhdlGenerator glob pattern title to include .vhdl extension - Fix 7: split ParsePackage into parent + 7 discrete child requirements (ParsePackageTypes, ParsePackageConstants, ParsePackageComponents, ParsePackageSubprograms, ParseSubprogramParameters, ParseSubprogramReturnType, ParseSubprogramDocComments) in vhdl-ast-parser.yaml - Fix 8: add CounterVhd property to FixturePaths; add entity name and generic inline doc comment tests to VhdlAstParserTests.cs - Fix 9: update api-mark-vhdl.md verification acceptance criteria (precise entity name, generic comments, syntax error rejection) and add three new test scenarios --- docs/design/api-mark-vhdl.md | 8 +-- docs/design/api-mark-vhdl/vhdl-ast-parser.md | 2 - .../api-mark-vhdl/vhdl-ast-parser.yaml | 70 +++++++++++++++++-- .../vhdl-emitter-gradual-disclosure.yaml | 9 ++- .../reqstream/api-mark-vhdl/vhdl-emitter.yaml | 3 +- .../api-mark-vhdl/vhdl-generator.yaml | 2 +- docs/verification/api-mark-vhdl.md | 21 +++++- .../vhdl-emitter-gradual-disclosure.md | 26 ++++++- test/ApiMark.Vhdl.Tests/FixturePaths.cs | 3 + test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs | 42 +++++++++-- test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs | 12 ++++ 11 files changed, 173 insertions(+), 25 deletions(-) diff --git a/docs/design/api-mark-vhdl.md b/docs/design/api-mark-vhdl.md index 1c42356..445647c 100644 --- a/docs/design/api-mark-vhdl.md +++ b/docs/design/api-mark-vhdl.md @@ -25,8 +25,8 @@ by the Core interfaces. The system contains the following units: declarations, and returns a `VhdlFileModel`. - **VhdlEmitter** — `IApiEmitter` implementation that validates the mandatory factory argument and dispatches to the appropriate format-specific emitter. -- **VhdlEmitterGradualDisclosure** — writes one file per entity, architecture, - and package, plus an `api.md` index page listing all entities and packages. +- **VhdlEmitterGradualDisclosure** — writes one file per entity and package, with architectures + rendered inline on entity detail pages, plus an `api.md` index page. - **VhdlEmitterSingleFile** — writes all documentation into a single `api.md` file using heading levels offset by `EmitConfig.HeadingDepth`. @@ -78,7 +78,7 @@ ANTLR4 parser to parse VHDL-2008 source files. ## Dependencies - **Antlr4.Runtime.Standard**: NuGet package providing the ANTLR4 runtime for parsing - VHDL source files using the pre-generated vhdl2008 grammar. + VHDL source files using the pre-generated vhdl2008 grammar (see [ANTLR4](ots/antlr4.md)). ## Risk Control Measures @@ -117,7 +117,7 @@ N/A - not a safety-classified software item. ## Design Constraints -- Platform: targets net8.0 as a class library (the project targets net8.0 only). +- Platform: .NET class library. - Parse environment: ANTLR4 vhdl2008 grammar discards all comments via skip rules; doc comment extraction is performed by pre-processing source lines independently of the ANTLR parse step. diff --git a/docs/design/api-mark-vhdl/vhdl-ast-parser.md b/docs/design/api-mark-vhdl/vhdl-ast-parser.md index 29f3a03..d9b602d 100644 --- a/docs/design/api-mark-vhdl/vhdl-ast-parser.md +++ b/docs/design/api-mark-vhdl/vhdl-ast-parser.md @@ -148,8 +148,6 @@ listener that accumulates syntax errors and throws rather than writing to - **ThrowIfErrors**: throws `InvalidOperationException` with all collected messages when at least one error was recorded; no-op otherwise. -a `VhdlDocComment`. - **VhdlAstParser.ParseDocCommentLines** (private static): Parses a list of `--!` comment lines into a `VhdlDocComment`, recognising `@brief`, `@param`, and `@return` tags. diff --git a/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml b/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml index 4571d6f..102957f 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml @@ -25,23 +25,85 @@ sections: - VhdlAstParser_Parse_MuxFixture_ParsesTwoArchitectures - VhdlAstParser_Parse_MuxFixture_HasMuxEntity - id: ApiMarkVhdl-VhdlAstParser-ParsePackage - title: VhdlAstParser shall parse package declarations including types, - constants, components, and subprograms. + title: VhdlAstParser shall parse package declarations from a .vhd file, + capturing the package name. justification: | - Packages are the primary reuse mechanism in VHDL and may contain all four declaration kinds. + Packages are the primary reuse mechanism in VHDL. Capturing the package + name is the entry point for all further package content parsing. + children: + - ApiMarkVhdl-VhdlAstParser-ParsePackageTypes + - ApiMarkVhdl-VhdlAstParser-ParsePackageConstants + - ApiMarkVhdl-VhdlAstParser-ParsePackageComponents + - ApiMarkVhdl-VhdlAstParser-ParsePackageSubprograms + - ApiMarkVhdl-VhdlAstParser-ParseSubprogramParameters + - ApiMarkVhdl-VhdlAstParser-ParseSubprogramReturnType + - ApiMarkVhdl-VhdlAstParser-ParseSubprogramDocComments tests: - VhdlAstParser_Parse_CommonTypesFixture_ReturnsPackage + - id: ApiMarkVhdl-VhdlAstParser-ParsePackageTypes + title: VhdlAstParser shall parse type declarations within a package. + justification: | + Type declarations are a fundamental package member kind in VHDL and must + be captured to generate complete package documentation. + tests: - VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoTypes + - id: ApiMarkVhdl-VhdlAstParser-ParsePackageConstants + title: VhdlAstParser shall parse constant declarations within a package, + including any associated doc comments. + justification: | + Constant declarations with their accompanying doc comments allow + consumers to understand the purpose of each constant from the generated + documentation. + tests: - VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoConstants - VhdlAstParser_Parse_CommonTypesFixture_ConstantsHaveDocComments + - id: ApiMarkVhdl-VhdlAstParser-ParsePackageComponents + title: VhdlAstParser shall parse component declarations within a package. + justification: | + Component declarations identify reusable design units and must be + captured to list available components in the generated documentation. + tests: - VhdlAstParser_Parse_CommonTypesFixture_PackageHasOneComponent + - id: ApiMarkVhdl-VhdlAstParser-ParsePackageSubprograms + title: VhdlAstParser shall parse subprogram declarations within a package, + distinguishing functions from procedures. + justification: | + Subprogram declarations represent the callable interface of a package; + distinguishing functions from procedures enables accurate API documentation. + children: + - ApiMarkVhdl-VhdlAstParser-ParseSubprogramParameters + - ApiMarkVhdl-VhdlAstParser-ParseSubprogramReturnType + - ApiMarkVhdl-VhdlAstParser-ParseSubprogramDocComments + tests: - VhdlAstParser_Parse_CommonTypesFixture_PackageHasTwoSubprograms - VhdlAstParser_Parse_CommonTypesFixture_ToNaturalIsFunction - VhdlAstParser_Parse_CommonTypesFixture_ClearVectorIsProcedure + - id: ApiMarkVhdl-VhdlAstParser-ParseSubprogramParameters + title: VhdlAstParser shall parse formal parameters of subprogram declarations, + capturing name, type, and mode. + justification: | + Formal parameters define the calling contract of a subprogram; capturing + name, type, and mode enables full signature documentation. + tests: - VhdlAstParser_Parse_CommonTypesFixture_ToNaturalHasOneParameter - - VhdlAstParser_Parse_CommonTypesFixture_ToNaturalHasReturnTypeNatural - VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasOneParameter + - id: ApiMarkVhdl-VhdlAstParser-ParseSubprogramReturnType + title: VhdlAstParser shall parse the return type of function subprogram + declarations, and record null for procedures. + justification: | + The return type distinguishes functions from procedures in generated + documentation; recording null for procedures keeps the model consistent. + tests: + - VhdlAstParser_Parse_CommonTypesFixture_ToNaturalHasReturnTypeNatural - VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasNullReturnType + - id: ApiMarkVhdl-VhdlAstParser-ParseSubprogramDocComments + title: VhdlAstParser shall parse --! doc comments on subprograms, capturing + @param and @return entries. + justification: | + Doc comment @param and @return entries carry human-readable descriptions + for each parameter and the return value; capturing them enables richer + generated documentation. + tests: - VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasParamEntry - VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasReturnEntry - id: ApiMarkVhdl-VhdlAstParser-RejectInvalidSyntax diff --git a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml index 642d8a2..490ab5d 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml @@ -26,8 +26,7 @@ sections: AI can navigate from the package index to individual subprogram signatures without loading the entire package page. tests: - - VhdlEmitterGradualDisclosure_Emit_CreatesSubprogramDetailFile - - VhdlEmitterGradualDisclosure_Emit_SubprogramDetailFileHasSignatureHeading - - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprogram_CreatesSubprogramDetailPage - - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprogram_SubprogramPageContainsSignatureHeading - - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprogram_SubprogramPageContainsSummary + - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprograms_CreatesSubprogramDetailFile + - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprograms_SubprogramDetailFileHasSignatureHeading + - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprograms_EmitsSubprogramsSection + - VhdlEmitterGradualDisclosure_Emit_PackageWithSubprograms_PackagePageLinkUsesSubfolderPath diff --git a/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml b/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml index 323e307..73d3a61 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml @@ -14,7 +14,8 @@ sections: children: - ApiMarkVhdl-VhdlEmitter-DispatchesFormat - ApiMarkVhdl-VhdlEmitter-ValidatesNullFactory - tests: [] + tests: + - VhdlEmitter_ImplementsIApiEmitter - id: ApiMarkVhdl-VhdlEmitter-DispatchesFormat title: VhdlEmitter shall dispatch to VhdlEmitterGradualDisclosure or VhdlEmitterSingleFile based on config.Format. diff --git a/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml b/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml index 633128c..87b2328 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml @@ -7,7 +7,7 @@ sections: requirements: - id: ApiMarkVhdl-VhdlGenerator-AcceptSourceGlobPatterns title: VhdlGenerator shall accept one or more glob patterns that select - which .vhd source files to document, using gitignore-style last-match-wins + which .vhd and .vhdl source files to document, using gitignore-style last-match-wins semantics with ! prefix exclusion patterns. justification: | VHDL projects organize IP cores as individual files or as directories of .vhd diff --git a/docs/verification/api-mark-vhdl.md b/docs/verification/api-mark-vhdl.md index b0dcfca..90573a0 100644 --- a/docs/verification/api-mark-vhdl.md +++ b/docs/verification/api-mark-vhdl.md @@ -21,14 +21,16 @@ additional toolchain dependency is required — the ANTLR4 runtime is a NuGet pa ## Acceptance Criteria - All ApiMarkVhdl tests pass with zero failures. -- The parser correctly extracts entity names, generics, and ports from the counter fixture file. +- The parser correctly extracts the entity name, generics, and ports from the counter fixture file, + including the entity name `counter`. - Preceding --! block comments are associated with entity declarations. -- Inline --! trailing comments are associated with port and generic declarations. +- Inline --! trailing comments are associated with port declarations and with generic declarations. - The parser correctly extracts both architecture bodies from the mux fixture file. - The parser correctly extracts a package declaration including types, constants, components, and subprograms from the common_types fixture file. - The gradual-disclosure emitter creates an api index page and at least one entity page. - The single-file emitter creates exactly one file. +- The parser throws `InvalidOperationException` when given a file with invalid VHDL syntax. ## Test Scenarios @@ -90,3 +92,18 @@ creates exactly one Markdown file. Tested by **Single-file emitter creates api file only**: Verifies that the single-file emitter creates only the api.md file. Tested by `VhdlEmitterSingleFile_Emit_MinimalData_CreatesApiFileOnly`. + +**Syntax error rejects with exception**: Verifies that `VhdlAstParser.Parse` throws +`InvalidOperationException` when the source file contains invalid VHDL syntax, preventing +silently corrupt output. Tested by +`VhdlAstParser_Parse_InvalidVhdl_ThrowsInvalidOperationException`. + +**Counter entity name is counter**: Verifies that `VhdlAstParser.Parse` returns the entity +name `counter` from the counter fixture file, confirming that entity names are extracted +correctly from source. Tested by +`VhdlAstParser_Parse_CounterFixture_EntityNameIsCounter`. + +**Counter generics have inline doc comments**: Verifies that at least one generic in the +counter fixture has a non-empty inline --! doc comment, confirming that inline trailing +comments are associated with generic declarations as well as port declarations. Tested by +`VhdlAstParser_Parse_CounterFixture_GenericsHaveInlineDocComments`. diff --git a/docs/verification/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md b/docs/verification/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md index f9f9b53..a4fba6a 100644 --- a/docs/verification/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-vhdl/vhdl-emitter-gradual-disclosure.md @@ -45,4 +45,28 @@ architectures, those architectures appear as an inline section on the entity det than as separate files, confirming that the gradual-disclosure emitter does not create standalone architecture pages. This scenario is tested by -`VhdlEmitterGradualDisclosure_Emit_EntityWithArchitecture_ArchitectureSectionAppearsInEntityPage`. +`VhdlEmitterGradualDisclosure_Emit_WithArchitecture_EntityPageHasInlineArchitecturesSection`. + +**Subprogram detail file is created**: Verifies that for each subprogram declared in a package, +the gradual-disclosure emitter creates a dedicated detail file placed under a per-package +subfolder (`{packageName}/{subprogramName}.md`). +This scenario is tested by +`VhdlEmitterGradualDisclosure_Emit_PackageWithSubprograms_CreatesSubprogramDetailFile`. + +**Subprogram detail file has Signature heading**: Verifies that the subprogram detail file +contains a Signature heading, confirming that the subprogram signature text is rendered in the +detail page. +This scenario is tested by +`VhdlEmitterGradualDisclosure_Emit_PackageWithSubprograms_SubprogramDetailFileHasSignatureHeading`. + +**Package page link uses subfolder path**: Verifies that the package page paragraph linking to a +subprogram uses the subfolder path format (`{packageName}/{subprogramName}.md`), confirming +correct relative navigation between the package index and subprogram detail pages. +This scenario is tested by +`VhdlEmitterGradualDisclosure_Emit_PackageWithSubprograms_PackagePageLinkUsesSubfolderPath`. + +**Package page emits Subprograms section**: Verifies that the package page includes a Subprograms +section listing each subprogram, confirming that the package detail page provides a navigable +index of its declared subprograms. +This scenario is tested by +`VhdlEmitterGradualDisclosure_Emit_PackageWithSubprograms_EmitsSubprogramsSection`. diff --git a/test/ApiMark.Vhdl.Tests/FixturePaths.cs b/test/ApiMark.Vhdl.Tests/FixturePaths.cs index 1b05702..9b5bbb0 100644 --- a/test/ApiMark.Vhdl.Tests/FixturePaths.cs +++ b/test/ApiMark.Vhdl.Tests/FixturePaths.cs @@ -14,6 +14,9 @@ internal static class FixturePaths /// Gets the absolute path to the mux.vhd fixture file. public static string MuxVhd => GetFixtureFilePath("mux.vhd"); + /// Gets the absolute path to the counter.vhd fixture file. + public static string CounterVhd => GetFixtureFilePath("counter.vhd"); + /// Gets the absolute path to the common_types.vhd fixture file. public static string CommonTypesVhd => GetFixtureFilePath("common_types.vhd"); diff --git a/test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs b/test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs index ec71152..afed818 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlAstParserTests.cs @@ -286,7 +286,7 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasOneParameter() // Act: invoke parser on the common types fixture var model = VhdlAstParser.Parse(path); - // Assert: clear_vector must have one parameter with SIGNAL or OUT in the mode string + // Assert: clear_vector must have one parameter with combined SIGNAL OUT mode var pkg = Assert.Single(model.Packages); var subprogram = pkg.Subprograms.FirstOrDefault(s => s.Name == "clear_vector"); Assert.NotNull(subprogram); @@ -294,11 +294,11 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ClearVectorHasOneParameter() Assert.Equal("v", param.Name); Assert.Equal("STD_LOGIC_VECTOR", param.TypeName); - // Mode contains SIGNAL (class keyword) and/or OUT (direction) from `SIGNAL v : OUT STD_LOGIC_VECTOR` + // Mode must contain both SIGNAL (class keyword) and OUT (direction) from `SIGNAL v : OUT STD_LOGIC_VECTOR` Assert.True( - param.Mode.Contains("SIGNAL", StringComparison.Ordinal) || + param.Mode.Contains("SIGNAL", StringComparison.Ordinal) && param.Mode.Contains("OUT", StringComparison.Ordinal), - $"Expected mode to contain 'SIGNAL' or 'OUT', got '{param.Mode}'"); + $"Expected mode to contain both 'SIGNAL' and 'OUT', got '{param.Mode}'"); } /// Validates that clear_vector has a null return type because it is a procedure. @@ -359,7 +359,7 @@ public void VhdlAstParser_Parse_CommonTypesFixture_ToNaturalDocHasReturnEntry() public void VhdlAstParser_Parse_InvalidVhdl_ThrowsInvalidOperationException() { // Arrange: write deliberately invalid VHDL content to a temp file - var tempFile = Path.GetTempFileName() + ".vhd"; + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".vhd"); try { File.WriteAllText(tempFile, "this is not valid vhdl syntax!!!"); @@ -376,4 +376,36 @@ public void VhdlAstParser_Parse_InvalidVhdl_ThrowsInvalidOperationException() } } } + + /// Validates that the entity name from the counter fixture is "counter". + [Fact] + public void VhdlAstParser_Parse_CounterFixture_EntityNameIsCounter() + { + // Arrange: resolve path to counter.vhd fixture file + var path = FixturePaths.CounterVhd; + + // Act: invoke parser on the counter fixture + var model = VhdlAstParser.Parse(path); + + // Assert: the single entity must be named "counter" + var entity = Assert.Single(model.Entities); + Assert.Equal("counter", entity.Name); + } + + /// Validates that generics in the counter fixture have inline --! doc comments. + [Fact] + public void VhdlAstParser_Parse_CounterFixture_GenericsHaveInlineDocComments() + { + // Arrange: resolve path to counter.vhd fixture file + var path = FixturePaths.CounterVhd; + + // Act: invoke parser on the counter fixture + var model = VhdlAstParser.Parse(path); + + // Assert: at least one generic must have a non-empty inline doc comment + var entity = Assert.Single(model.Entities); + Assert.True( + entity.Generics.Any(g => g.Doc != null && !string.IsNullOrEmpty(g.Doc.Summary)), + "Expected at least one generic to have an inline --! doc comment"); + } } diff --git a/test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs b/test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs index 1f3970f..e08652f 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlEmitterTests.cs @@ -8,6 +8,18 @@ namespace ApiMark.Vhdl.Tests; /// Unit tests for . public class VhdlEmitterTests { + /// Validates that VhdlEmitter implements the IApiEmitter interface. + [Fact] + public void VhdlEmitter_ImplementsIApiEmitter() + { + // Arrange: create a minimal emitter + var options = new VhdlGeneratorOptions { LibraryName = "TestLib" }; + var emitter = new VhdlEmitter(options, []); + + // Assert: VhdlEmitter must be assignable to IApiEmitter + Assert.IsAssignableFrom(emitter); + } + /// Validates that passing null factory to Emit throws ArgumentNullException. [Fact] public void VhdlEmitter_Emit_NullFactory_ThrowsArgumentNullException() From b919236a3d3747d6a6757487ce3766938c93f54d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 19:28:12 -0400 Subject: [PATCH 24/62] fix: VHDL R3 review fixes - Replace markdown link with prose cross-reference for ANTLR4 OTS doc (design documentation standard requires verbal not hyperlink references) - Add inline architecture acceptance criterion and test scenario to docs/verification/api-mark-vhdl.md - Add ApiMarkVhdl-VhdlGenerator-ValidatesLibraryName requirement with Empty/Whitespace constructor test links (moved from AcceptSourceGlobPatterns) - Add EntityNameIsCounter and GenericsHaveInlineDocComments test links to ApiMarkVhdl-VhdlAstParser-ParseEntity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-vhdl.md | 3 ++- docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml | 2 ++ docs/reqstream/api-mark-vhdl/vhdl-generator.yaml | 12 ++++++++++-- docs/verification/api-mark-vhdl.md | 9 +++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/design/api-mark-vhdl.md b/docs/design/api-mark-vhdl.md index 445647c..0840cdb 100644 --- a/docs/design/api-mark-vhdl.md +++ b/docs/design/api-mark-vhdl.md @@ -78,7 +78,8 @@ ANTLR4 parser to parse VHDL-2008 source files. ## Dependencies - **Antlr4.Runtime.Standard**: NuGet package providing the ANTLR4 runtime for parsing - VHDL source files using the pre-generated vhdl2008 grammar (see [ANTLR4](ots/antlr4.md)). + VHDL source files using the pre-generated vhdl2008 grammar; see the ANTLR4 OTS design + document. ## Risk Control Measures diff --git a/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml b/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml index 102957f..cfd981b 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-ast-parser.yaml @@ -16,6 +16,8 @@ sections: - VhdlAstParser_Parse_FixtureFile_EntityHasPorts - VhdlAstParser_Parse_FixtureFile_EntityDocCommentParsed - VhdlAstParser_Parse_FixtureFile_PortsHaveInlineDocComments + - VhdlAstParser_Parse_CounterFixture_EntityNameIsCounter + - VhdlAstParser_Parse_CounterFixture_GenericsHaveInlineDocComments - id: ApiMarkVhdl-VhdlAstParser-ParseArchitecture title: VhdlAstParser shall parse architecture bodies from a .vhd file, capturing the architecture name and associated entity name. diff --git a/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml b/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml index 87b2328..996e8eb 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml @@ -18,11 +18,19 @@ sections: - ApiMarkVhdl-VhdlGenerator-ReportNoFilesMatched tests: - VhdlGenerator_Constructor_NullOptions_ThrowsArgumentNullException - - VhdlGenerator_Constructor_EmptyLibraryName_ThrowsArgumentException - - VhdlGenerator_Constructor_WhitespaceLibraryName_ThrowsArgumentException - VhdlGenerator_Generate_FixtureFile_CreatesApiEntrypoint - VhdlGenerator_Generate_FixtureFile_CreatesEntityPage - VhdlGenerator_Generate_AllFixtures_ProducesExpectedOutputStructure + - id: ApiMarkVhdl-VhdlGenerator-ValidatesLibraryName + title: VhdlGenerator shall throw ArgumentException when the LibraryName option + is null, empty, or whitespace-only. + justification: | + An empty or whitespace library name would produce a meaningless documentation + heading. Failing fast at construction provides a clear error before any parsing + or emission occurs. + tests: + - VhdlGenerator_Constructor_EmptyLibraryName_ThrowsArgumentException + - VhdlGenerator_Constructor_WhitespaceLibraryName_ThrowsArgumentException - id: ApiMarkVhdl-VhdlGenerator-ReportNoFilesMatched title: VhdlGenerator shall report an error via the context when no source files match the configured glob patterns. diff --git a/docs/verification/api-mark-vhdl.md b/docs/verification/api-mark-vhdl.md index 90573a0..347b46a 100644 --- a/docs/verification/api-mark-vhdl.md +++ b/docs/verification/api-mark-vhdl.md @@ -29,6 +29,8 @@ additional toolchain dependency is required — the ANTLR4 runtime is a NuGet pa - The parser correctly extracts a package declaration including types, constants, components, and subprograms from the common_types fixture file. - The gradual-disclosure emitter creates an api index page and at least one entity page. +- When an entity has associated architectures, the gradual-disclosure emitter renders them + inline on the entity detail page (not as separate files), including source-filename attribution. - The single-file emitter creates exactly one file. - The parser throws `InvalidOperationException` when given a file with invalid VHDL syntax. @@ -85,6 +87,13 @@ creates an entity detail page. Tested by heading contains the library name. Tested by `VhdlEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibraryNameHeading`. +**Gradual emitter renders architectures inline on entity page**: Verifies that when an entity +has associated architectures, the gradual-disclosure emitter renders them as an inline section +on the entity detail page rather than creating separate architecture files. Also verifies that +the architecture paragraph includes the source filename for attribution. Tested by +`VhdlEmitterGradualDisclosure_Emit_WithArchitecture_EntityPageHasInlineArchitecturesSection` +and `VhdlEmitterGradualDisclosure_Emit_WithArchitecture_ArchitectureParagraphContainsFilename`. + **Single-file emitter creates exactly one writer**: Verifies that the single-file emitter creates exactly one Markdown file. Tested by `VhdlEmitterSingleFile_Emit_MinimalData_CreatesExactlyOneWriter`. From c23172f226554b7ae83069e4e5a3e18129d2a1d3 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 19:36:42 -0400 Subject: [PATCH 25/62] fix: VHDL R4 AllRequirements fixes - Add VhdlGenerator_Parse_InvalidVhdlFile_EmitsErrorAndSkipsFile test - Add ApiMarkVhdl-VhdlGenerator-ReportParseErrors requirement with test link - Add architecture-inline test links to VhdlEmitterGradualDisclosure requirement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../vhdl-emitter-gradual-disclosure.yaml | 2 ++ .../api-mark-vhdl/vhdl-generator.yaml | 9 +++++ test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs | 33 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml index 490ab5d..0bb8633 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml @@ -17,6 +17,8 @@ sections: - VhdlEmitterGradualDisclosure_Emit_MinimalData_CreatesApiIndexPage - VhdlEmitterGradualDisclosure_Emit_MinimalData_CreatesEntityPage - VhdlEmitterGradualDisclosure_Emit_MinimalData_ApiIndexContainsLibraryNameHeading + - VhdlEmitterGradualDisclosure_Emit_WithArchitecture_EntityPageHasInlineArchitecturesSection + - VhdlEmitterGradualDisclosure_Emit_WithArchitecture_ArchitectureParagraphContainsFilename - id: ApiMarkVhdl-VhdlEmitterGradualDisclosure-EmitSubprogramDetailPage title: VhdlEmitterGradualDisclosure shall write one detail file per subprogram declared in a package, placed under a per-package subfolder diff --git a/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml b/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml index 996e8eb..1cdfbb2 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-generator.yaml @@ -40,3 +40,12 @@ sections: generation to the caller. tests: - VhdlGenerator_Parse_NoFilesMatched_EmitsErrorAndReturnsEmptyEmitter + - id: ApiMarkVhdl-VhdlGenerator-ReportParseErrors + title: VhdlGenerator shall report an error via the context when a source + file fails to parse, and continue processing remaining files. + justification: | + Continuing after a parse failure allows other files in the set to be + documented even when one file is malformed, and the error report + identifies which file caused the problem. + tests: + - VhdlGenerator_Parse_InvalidVhdlFile_EmitsErrorAndSkipsFile diff --git a/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs b/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs index 10b7c88..aa5b80c 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs @@ -135,4 +135,37 @@ public void VhdlGenerator_Generate_AllFixtures_ProducesExpectedOutputStructure() "Expected a page containing 'common_types'"); Assert.DoesNotContain(factory.Writers.Keys, k => k.Contains("_arch", StringComparison.Ordinal)); } + + /// Validates that a file that fails to parse emits an error and is skipped. + [Fact] + public void VhdlGenerator_Parse_InvalidVhdlFile_EmitsErrorAndSkipsFile() + { + // Arrange: write deliberately invalid VHDL to a temp .vhd file + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".vhd"); + try + { + File.WriteAllText(tempFile, "this is not valid vhdl syntax!!!"); + var options = new VhdlGeneratorOptions + { + LibraryName = "TestLib", + WorkingDirectory = Path.GetDirectoryName(tempFile)!, + Sources = [Path.GetFileName(tempFile)], + }; + var generator = new VhdlGenerator(options); + var context = new InMemoryContext(); + + // Act: parse with an invalid VHDL file + generator.Parse(context); + + // Assert: an error message was written for the failed file + Assert.NotEmpty(context.Errors); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } } From 821e6739cc92922812e3109902660935be129fd0 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 19:41:32 -0400 Subject: [PATCH 26/62] fix: VHDL R5 VhdlGenerator fixes - Strengthen VhdlGenerator_Parse_InvalidVhdlFile_EmitsErrorAndSkipsFile test to use a mixed valid/invalid temp directory and assert valid files still produce output - Add ReportParseErrors acceptance criterion and scenario to vhdl-generator.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-mark-vhdl/vhdl-generator.md | 8 ++++++ test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs | 27 ++++++++++--------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/verification/api-mark-vhdl/vhdl-generator.md b/docs/verification/api-mark-vhdl/vhdl-generator.md index a177b13..02f6da7 100644 --- a/docs/verification/api-mark-vhdl/vhdl-generator.md +++ b/docs/verification/api-mark-vhdl/vhdl-generator.md @@ -23,6 +23,8 @@ configuration are required. - Running `Generate` against the fixture files produces at least one entity detail page. - Running `Generate` against all fixture files produces the expected output structure including entity pages, a package page, and no standalone architecture pages. +- When one source file contains invalid VHDL, `Parse` reports an error via `context.WriteError` + and continues processing remaining valid files, which still produce output. ### Test Scenarios @@ -58,3 +60,9 @@ This scenario is tested by `VhdlGenerator_Generate_FixtureFile_CreatesEntityPage generator against all fixture files (counter.vhd, mux.vhd, common_types.vhd) produces entity pages, a package page, and no standalone architecture pages. This scenario is tested by `VhdlGenerator_Generate_AllFixtures_ProducesExpectedOutputStructure`. + +**Invalid file emits error and valid files still produce output**: Verifies that when +the source file set contains one invalid VHDL file and one valid VHDL file, the generator +reports an error via `context.WriteError` for the invalid file and still emits output for +the valid file. +This scenario is tested by `VhdlGenerator_Parse_InvalidVhdlFile_EmitsErrorAndSkipsFile`. diff --git a/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs b/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs index aa5b80c..0d55c5d 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlGeneratorTests.cs @@ -136,36 +136,37 @@ public void VhdlGenerator_Generate_AllFixtures_ProducesExpectedOutputStructure() Assert.DoesNotContain(factory.Writers.Keys, k => k.Contains("_arch", StringComparison.Ordinal)); } - /// Validates that a file that fails to parse emits an error and is skipped. + /// Validates that a file that fails to parse emits an error and is skipped while valid files still produce output. [Fact] public void VhdlGenerator_Parse_InvalidVhdlFile_EmitsErrorAndSkipsFile() { - // Arrange: write deliberately invalid VHDL to a temp .vhd file - var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".vhd"); + // Arrange: create a temp directory with one valid and one invalid .vhd file + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); try { - File.WriteAllText(tempFile, "this is not valid vhdl syntax!!!"); + File.WriteAllText(Path.Combine(tempDir, "valid.vhd"), "ENTITY minimal IS\nEND ENTITY minimal;\n"); + File.WriteAllText(Path.Combine(tempDir, "invalid.vhd"), "this is not valid vhdl syntax!!!"); var options = new VhdlGeneratorOptions { LibraryName = "TestLib", - WorkingDirectory = Path.GetDirectoryName(tempFile)!, - Sources = [Path.GetFileName(tempFile)], + WorkingDirectory = tempDir, + Sources = ["*.vhd"], }; var generator = new VhdlGenerator(options); + var factory = new InMemoryMarkdownWriterFactory(); var context = new InMemoryContext(); - // Act: parse with an invalid VHDL file - generator.Parse(context); + // Act: parse both files — invalid one should be skipped, valid one processed + generator.Parse(context).Emit(factory, new EmitConfig(), context); - // Assert: an error message was written for the failed file + // Assert: error emitted for the invalid file; valid file still produced output Assert.NotEmpty(context.Errors); + Assert.NotEmpty(factory.Writers); } finally { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } + Directory.Delete(tempDir, recursive: true); } } } From b92c3955c28089bbd2151ca99222f83fe9b527e2 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 20:02:57 -0400 Subject: [PATCH 27/62] Make RunToolProcess protected virtual and add non-zero exit unit test - Remove sealed modifier from ApiMarkTask to allow test subclassing - Change RunToolProcess from private to protected virtual to provide a testability seam for overriding process execution in unit tests - Add FailingApiMarkTask test subclass that overrides RunToolProcess to simulate a non-zero exit without spawning a real child process - Add ApiMarkTask_Execute_ToolExitsNonZero_ReturnsFalseAndLogsError test verifying Execute returns false and logs an MSBuild error on non-zero exit - Split ApiMarkMsbuild-ApiMarkTask-SupportOutputsItemGroup compound requirement into three focused requirements: SupportOutputsItemGroup (parent), OverrideOutputProperties, and RestoreOutputProperties - Add ApiMarkMsbuild-ApiMarkTask-ResolveDotNetExecutable requirement for dotnet host path resolution behavior - Add ApiMarkMsbuild-ApiMarkTask-SurfaceToolExitCode requirement linked to the new test - Add ApiMarkMsbuild-ApiMarkTask-ForwardStdoutToLog requirement for stdout forwarding behavior - Replace known-gap notes in both verification docs with confirmed acceptance criteria - Add non-zero exit test scenario to api-mark-task verification doc --- .../api-mark-msbuild/api-mark-task.yaml | 56 +++++++++++++++++-- docs/verification/api-mark-msbuild.md | 4 +- .../api-mark-msbuild/api-mark-task.md | 10 +++- src/ApiMark.MSBuild/ApiMarkTask.cs | 4 +- .../ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs | 48 +++++++++++++++- 5 files changed, 108 insertions(+), 14 deletions(-) diff --git a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml index cf36c06..54e8eef 100644 --- a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml +++ b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml @@ -160,11 +160,8 @@ sections: - ApiMarkTask_Format_NotForwarded_WhenNotSet - id: ApiMarkMsbuild-ApiMarkTask-SupportOutputsItemGroup title: >- - ApiMarkTask shall generate documentation for each ApiMarkOutputs item - using that - item's per-output metadata (OutputDir, Format, Visibility) to control - where and how - each output is written. + ApiMarkTask shall generate documentation for each item in the + ApiMarkOutputs ItemGroup when it is non-empty. justification: | Projects that need multiple output formats or visibility levels from a single build must be able to declare named outputs via the ApiMarkOutputs ItemGroup, each carrying @@ -173,3 +170,52 @@ sections: tests: - ApiMarkTask_BuildArgumentsForOutput_OverridesScalarPropertiesFromMetadata - ApiMarkTask_BuildArgumentsForOutput_RestoresScalarPropertiesAfterCall + - id: ApiMarkMsbuild-ApiMarkTask-OverrideOutputProperties + title: >- + ApiMarkTask shall override OutputDir, Visibility, and Format from each + ApiMarkOutputs item's metadata when building arguments for that output. + justification: | + Each named output in the ApiMarkOutputs ItemGroup must be able to specify its own + output directory, visibility level, and format independently so that a single build + invocation can produce multiple distinct documentation artifacts. + tests: + - ApiMarkTask_BuildArgumentsForOutput_OverridesScalarPropertiesFromMetadata + - id: ApiMarkMsbuild-ApiMarkTask-RestoreOutputProperties + title: >- + ApiMarkTask shall restore the original OutputDir, Visibility, and Format + values after building arguments for each output item. + justification: | + Scalar property values must be restored after each per-output invocation so that + subsequent items and the scalar invocation path continue to use the original + project-level property values, avoiding cross-contamination between outputs. + tests: + - ApiMarkTask_BuildArgumentsForOutput_RestoresScalarPropertiesAfterCall + - id: ApiMarkMsbuild-ApiMarkTask-ResolveDotNetExecutable + title: >- + ApiMarkTask shall locate the dotnet executable by checking DOTNET_HOST_PATH + first and then searching PATH, and shall return false with an error if neither + source provides a valid executable path. + justification: | + The .NET SDK sets DOTNET_HOST_PATH to the exact dotnet host; preferring it over + PATH avoids confusion when multiple SDK versions are installed. Failing fast with + a clear error prevents a silent no-op build when the SDK is absent. + tests: + - ApiMarkTask_Execute_ToolExitsNonZero_ReturnsFalseAndLogsError + - id: ApiMarkMsbuild-ApiMarkTask-SurfaceToolExitCode + title: >- + ApiMarkTask shall return false and log a MSBuild error when the spawned + ApiMark.Tool process exits with a non-zero exit code. + justification: | + MSBuild builds must fail visibly when documentation generation fails. Returning + false from Execute causes MSBuild to mark the target as failed and stop the build, + while the logged error message identifies the exit code for diagnosis. + tests: + - ApiMarkTask_Execute_ToolExitsNonZero_ReturnsFalseAndLogsError + - id: ApiMarkMsbuild-ApiMarkTask-ForwardStdoutToLog + title: >- + ApiMarkTask shall forward the spawned tool's standard output to the MSBuild + build log as informational messages. + justification: | + ApiMark.Tool writes progress and status to stdout. Forwarding it to the MSBuild + log makes generation progress visible in the IDE output window and CI build logs. + tests: [] diff --git a/docs/verification/api-mark-msbuild.md b/docs/verification/api-mark-msbuild.md index 92352da..2723812 100644 --- a/docs/verification/api-mark-msbuild.md +++ b/docs/verification/api-mark-msbuild.md @@ -28,9 +28,7 @@ permission is required. - `ApiMarkOutputDir` and `ApiMarkVisibility` are forwarded correctly to the tool. - `ApiMarkIncludeObsolete` set to `true` adds the `--include-obsolete` flag to the spawned tool command. -- A non-zero exit code from the spawned tool is surfaced as an MSBuild build failure. Note: - process-failure surfacing (non-zero exit → task returns false + logs error) is a known - verification gap; a dedicated test is pending. +- A non-zero exit code from the spawned tool causes Execute to return false and log an MSBuild error. - When `ApiMarkPackDocs` is `true`, the generated `api/` folder is included in the NuGet package; when `false` or unset, the `api/` folder is not packaged. - For C++ projects, `ApiMarkLibraryName` is forwarded as `--library-name` when set. diff --git a/docs/verification/api-mark-msbuild/api-mark-task.md b/docs/verification/api-mark-msbuild/api-mark-task.md index f4eb0c6..10776ba 100644 --- a/docs/verification/api-mark-msbuild/api-mark-task.md +++ b/docs/verification/api-mark-msbuild/api-mark-task.md @@ -33,9 +33,8 @@ MSBuild and VC++ tools installed; those tests skip gracefully when the package i - `ApiMarkVisibility` is forwarded as `--visibility` when set. - `ApiMarkIncludeObsolete` is forwarded as `--include-obsolete` when true. - `DisableApiMark` suppresses tool invocation and returns true with no side effects. -- A non-zero exit code from the spawned tool causes `Execute` to return false and log a - MSBuild error. Note: process-failure surfacing (non-zero exit → task returns false + - logs error) is a known verification gap; a dedicated test is pending. +- A non-zero exit code from the spawned tool causes Execute to return false and log a + MSBuild error. - For C++ builds, `ApiMarkLibraryName` is forwarded as `--library-name` when set. - For C++ builds, `ApiMarkLibraryDescription` is forwarded as `--library-description` when set. - For C++ builds, `ApiMarkClangPath` is forwarded as `--clang-path` when set. @@ -163,3 +162,8 @@ properties (`OutputDir`, `Visibility`, `Format`) are restored to their original `BuildArgumentsForOutput` returns, so subsequent calls still use the original property values. This scenario is tested by `ApiMarkTask_BuildArgumentsForOutput_RestoresScalarPropertiesAfterCall`. + +**Non-zero tool exit returns false and logs error**: Verifies that when the spawned tool +process exits with a non-zero exit code, `Execute` returns false and a MSBuild error is +logged. This scenario is tested by +`ApiMarkTask_Execute_ToolExitsNonZero_ReturnsFalseAndLogsError`. diff --git a/src/ApiMark.MSBuild/ApiMarkTask.cs b/src/ApiMark.MSBuild/ApiMarkTask.cs index ec7718c..25a6944 100644 --- a/src/ApiMark.MSBuild/ApiMarkTask.cs +++ b/src/ApiMark.MSBuild/ApiMarkTask.cs @@ -18,7 +18,7 @@ namespace ApiMark.MSBuild; /// intentionally kept out-of-process in ApiMark.Tool, which targets net8.0 and may /// use libraries that do not support netstandard2.0. /// -public sealed class ApiMarkTask : Task +public class ApiMarkTask : Task { /// Language identifier for .NET documentation generation. private const string DotNetLanguage = "dotnet"; @@ -549,7 +549,7 @@ private bool ExecuteAllOutputs(string dotnetExe, string language) /// Full path to the dotnet executable. /// Ordered argument list starting with the language subcommand. /// true when the process exits with code zero; false otherwise. - private bool RunToolProcess(string dotnetExe, IReadOnlyList toolArgs) + protected virtual bool RunToolProcess(string dotnetExe, IReadOnlyList toolArgs) { // Configure the child process with redirected I/O so all output feeds the MSBuild log. // ArgumentList is used instead of Arguments so that the runtime applies correct diff --git a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs index fd5ffc9..970a06f 100644 --- a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs +++ b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs @@ -625,5 +625,51 @@ public void ApiMarkTask_BuildArgumentsForOutput_RestoresScalarPropertiesAfterCal Assert.True(formatIdx >= 0); Assert.Equal("gradual", afterArgs[formatIdx + 1]); } -} + /// + /// Validates that returns false and logs a + /// MSBuild error when the spawned tool process exits with a non-zero exit code. + /// + [Fact] + public void ApiMarkTask_Execute_ToolExitsNonZero_ReturnsFalseAndLogsError() + { + // Arrange: use the test assembly as ToolDllPath so File.Exists passes, then override + // RunToolProcess to simulate a non-zero exit without spawning a real child process + var buildEngine = Substitute.For(); + var task = new FailingApiMarkTask + { + BuildEngine = buildEngine, + ProjectExtension = ".csproj", + ToolDllPath = typeof(ApiMarkTaskTests).Assembly.Location, + ApiMarkAssemblyPath = "test.dll", + ApiMarkXmlDocPath = "test.xml", + }; + + // Act + var result = task.Execute(); + + // Assert: Execute must return false and at least one error must have been logged + Assert.False(result); + buildEngine.Received().LogErrorEvent(Arg.Any()); + } + + /// + /// Subclass of that overrides RunToolProcess to simulate + /// a non-zero tool exit without spawning a real child process. + /// + /// + /// This test helper exercises the exit-code error-surfacing path of + /// without requiring a real ApiMark.Tool + /// binary or a real dotnet executable to be present on the test host. + /// + private sealed class FailingApiMarkTask : ApiMarkTask + { + /// + protected override bool RunToolProcess(string dotnetExe, IReadOnlyList toolArgs) + { + // Simulate the exact error the real implementation logs on a non-zero exit + Log.LogError("ApiMark.Tool exited with code 1."); + return false; + } + } +} From b972cc9f03f87003df36ab11c440e49c38b9e347 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 20:16:49 -0400 Subject: [PATCH 28/62] feat(ApiMarkTask): add ResolveDotNetExe/stderr/stdout/multi-output tests and split dotnet-resolve requirement - Fix 1: correct ApiMarkOutput (item group) vs ApiMarkOutputs (task property) naming in SupportOutputsItemGroup requirement title and verification docs - Fix 2: change ResolveDotNetExe() from private static to protected virtual so test subclasses can override it; add NoDotNetApiMarkTask helper subclass - Fix 3: split ResolveDotNetExecutable requirement into two separate requirements: ApiMarkMsbuild-ApiMarkTask-ResolveDotNetFromEnvironment (DOTNET_HOST_PATH / PATH resolution, tested by the existing end-to-end test) and ApiMarkMsbuild-ApiMarkTask-ReportMissingDotNetExe (missing dotnet error path, tested by new ApiMarkTask_Execute_DotNetExeNotResolved_ReturnsFalseAndLogsError) - Fix 4: add LogMessageEvent assertion to ApiMarkTask_Execute_WithDotNetProject_ GeneratesDocumentation and link it to the ForwardStdoutToLog requirement - Fix 5: add ApiMarkMsbuild-ApiMarkTask-ForwardStderrErrors requirement and ApiMarkTask_Execute_ToolWritesToStderr_ForwardsToMsBuildErrors test that runs the real tool with invalid inputs to exercise the ErrorDataReceived forwarding path - Fix 6: add ApiMarkTask_Execute_WithMultipleOutputs_RunsToolForEachOutput test with RecordingApiMarkTask helper; link test to SupportOutputsItemGroup requirement and add verification scenario in both verification docs - Fix 7: update verification docs with new acceptance criteria and test scenarios for dotnet resolution failure, stdout forwarding, stderr forwarding, and multi-output execution All 26 MSBuild tests pass (4 new tests added). --- .../api-mark-msbuild/api-mark-task.yaml | 38 +++-- docs/verification/api-mark-msbuild.md | 8 ++ .../api-mark-msbuild/api-mark-task.md | 31 ++++ src/ApiMark.MSBuild/ApiMarkTask.cs | 2 +- .../ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs | 133 +++++++++++++++++- 5 files changed, 200 insertions(+), 12 deletions(-) diff --git a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml index 54e8eef..6829d0e 100644 --- a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml +++ b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml @@ -160,16 +160,17 @@ sections: - ApiMarkTask_Format_NotForwarded_WhenNotSet - id: ApiMarkMsbuild-ApiMarkTask-SupportOutputsItemGroup title: >- - ApiMarkTask shall generate documentation for each item in the - ApiMarkOutputs ItemGroup when it is non-empty. + ApiMarkTask shall generate documentation for each item in the ApiMarkOutput + item group (via the ApiMarkOutputs task property) when it is non-empty. justification: | Projects that need multiple output formats or visibility levels from a single build - must be able to declare named outputs via the ApiMarkOutputs ItemGroup, each carrying + must be able to declare named outputs via the ApiMarkOutput item group, each carrying its own OutputDir, Format, and Visibility metadata, so that the task produces distinct documentation artifacts without requiring separate build invocations. tests: - ApiMarkTask_BuildArgumentsForOutput_OverridesScalarPropertiesFromMetadata - ApiMarkTask_BuildArgumentsForOutput_RestoresScalarPropertiesAfterCall + - ApiMarkTask_Execute_WithMultipleOutputs_RunsToolForEachOutput - id: ApiMarkMsbuild-ApiMarkTask-OverrideOutputProperties title: >- ApiMarkTask shall override OutputDir, Visibility, and Format from each @@ -190,17 +191,24 @@ sections: project-level property values, avoiding cross-contamination between outputs. tests: - ApiMarkTask_BuildArgumentsForOutput_RestoresScalarPropertiesAfterCall - - id: ApiMarkMsbuild-ApiMarkTask-ResolveDotNetExecutable + - id: ApiMarkMsbuild-ApiMarkTask-ResolveDotNetFromEnvironment title: >- ApiMarkTask shall locate the dotnet executable by checking DOTNET_HOST_PATH - first and then searching PATH, and shall return false with an error if neither - source provides a valid executable path. + first and then searching PATH. justification: | The .NET SDK sets DOTNET_HOST_PATH to the exact dotnet host; preferring it over - PATH avoids confusion when multiple SDK versions are installed. Failing fast with - a clear error prevents a silent no-op build when the SDK is absent. + PATH avoids confusion when multiple SDK versions are installed. tests: - - ApiMarkTask_Execute_ToolExitsNonZero_ReturnsFalseAndLogsError + - ApiMarkTask_Execute_WithDotNetProject_GeneratesDocumentation + - id: ApiMarkMsbuild-ApiMarkTask-ReportMissingDotNetExe + title: >- + ApiMarkTask shall return false and log an MSBuild error when neither + DOTNET_HOST_PATH nor PATH provides a valid dotnet executable. + justification: | + Failing fast with a clear error prevents a silent no-op build when the SDK is + absent. + tests: + - ApiMarkTask_Execute_DotNetExeNotResolved_ReturnsFalseAndLogsError - id: ApiMarkMsbuild-ApiMarkTask-SurfaceToolExitCode title: >- ApiMarkTask shall return false and log a MSBuild error when the spawned @@ -218,4 +226,14 @@ sections: justification: | ApiMark.Tool writes progress and status to stdout. Forwarding it to the MSBuild log makes generation progress visible in the IDE output window and CI build logs. - tests: [] + tests: + - ApiMarkTask_Execute_WithDotNetProject_GeneratesDocumentation + - id: ApiMarkMsbuild-ApiMarkTask-ForwardStderrErrors + title: >- + ApiMarkTask shall forward the spawned tool's standard error output to the MSBuild + build log as error messages. + justification: | + ApiMark.Tool writes error information to stderr. Forwarding it as MSBuild errors + surfaces problems in the IDE error list and CI failure summary. + tests: + - ApiMarkTask_Execute_ToolWritesToStderr_ForwardsToMsBuildErrors diff --git a/docs/verification/api-mark-msbuild.md b/docs/verification/api-mark-msbuild.md index 2723812..afe98da 100644 --- a/docs/verification/api-mark-msbuild.md +++ b/docs/verification/api-mark-msbuild.md @@ -39,6 +39,8 @@ permission is required. flag in order, with `!`-prefixed exclusion patterns passed verbatim. - When `ApiMarkIncludePaths` is empty for a C++ project, the task returns success with no side effects. +- When `ApiMarkOutputs` is non-empty, the task spawns one child process per item in the + `ApiMarkOutput` item group. ## Test Scenarios @@ -114,3 +116,9 @@ This scenario is tested by **ApiMarkIncludeObsolete flag is forwarded**: Verifies that when `ApiMarkIncludeObsolete` is set to `true`, the `--include-obsolete` flag is added to the spawned tool command. This scenario is tested by `ApiMarkTask_IncludeObsolete_True_ForwardsIncludeObsoleteFlag`. + +**Multiple outputs spawn one tool process per item**: Verifies that when `ApiMarkOutputs` is +non-empty, `Execute` spawns one child process per item in the `ApiMarkOutput` item group, +applying per-item metadata overrides for `OutputDir`, `Format`, and `Visibility` so that +multiple documentation artifacts are produced in a single build invocation. This scenario is +tested by `ApiMarkTask_Execute_WithMultipleOutputs_RunsToolForEachOutput`. diff --git a/docs/verification/api-mark-msbuild/api-mark-task.md b/docs/verification/api-mark-msbuild/api-mark-task.md index 10776ba..9569a24 100644 --- a/docs/verification/api-mark-msbuild/api-mark-task.md +++ b/docs/verification/api-mark-msbuild/api-mark-task.md @@ -35,6 +35,12 @@ MSBuild and VC++ tools installed; those tests skip gracefully when the package i - `DisableApiMark` suppresses tool invocation and returns true with no side effects. - A non-zero exit code from the spawned tool causes Execute to return false and log a MSBuild error. +- When `dotnet` cannot be located via `DOTNET_HOST_PATH` or `PATH`, Execute returns false + and logs an MSBuild error. +- The spawned tool's standard output is forwarded to the MSBuild build log as informational + messages. +- The spawned tool's standard error output is forwarded to the MSBuild build log as error + messages. - For C++ builds, `ApiMarkLibraryName` is forwarded as `--library-name` when set. - For C++ builds, `ApiMarkLibraryDescription` is forwarded as `--library-description` when set. - For C++ builds, `ApiMarkClangPath` is forwarded as `--clang-path` when set. @@ -47,6 +53,9 @@ MSBuild and VC++ tools installed; those tests skip gracefully when the package i with no side effects. - When `ApiMarkXmlDocPath` is not set for a .NET project, the task returns true immediately with no side effects. +- When `ApiMarkOutputs` is non-empty, the task spawns one child process per item in the + `ApiMarkOutput` item group, passing per-item metadata overrides for `OutputDir`, `Format`, + and `Visibility`. ### Test Scenarios @@ -167,3 +176,25 @@ values. This scenario is tested by process exits with a non-zero exit code, `Execute` returns false and a MSBuild error is logged. This scenario is tested by `ApiMarkTask_Execute_ToolExitsNonZero_ReturnsFalseAndLogsError`. + +**dotnet executable not resolved returns false and logs error**: Verifies that when neither +`DOTNET_HOST_PATH` nor `PATH` provides a valid `dotnet` executable, `Execute` returns false +and logs an MSBuild error identifying the problem, allowing the build failure to be diagnosed +quickly. This scenario is tested by +`ApiMarkTask_Execute_DotNetExeNotResolved_ReturnsFalseAndLogsError`. + +**Stdout from spawned tool forwarded as informational messages**: Verifies that standard +output written by the spawned `ApiMark.Tool` child process is routed to the MSBuild build log +as informational messages, making generation progress visible in the IDE output window and CI +logs. This scenario is tested by `ApiMarkTask_Execute_WithDotNetProject_GeneratesDocumentation`. + +**Stderr from spawned tool forwarded as MSBuild errors**: Verifies that standard error output +written by the spawned tool is routed to the MSBuild build log as error messages, so +diagnostic information is surfaced in the IDE error list and CI failure summary. This scenario +is tested by `ApiMarkTask_Execute_ToolWritesToStderr_ForwardsToMsBuildErrors`. + +**Multiple outputs run tool once per output item**: Verifies that when `ApiMarkOutputs` is +non-empty, `Execute` delegates to `ExecuteAllOutputs` and calls `RunToolProcess` exactly once +per item in the `ApiMarkOutput` item group, using per-item metadata to override scalar +properties for each invocation. This scenario is tested by +`ApiMarkTask_Execute_WithMultipleOutputs_RunsToolForEachOutput`. diff --git a/src/ApiMark.MSBuild/ApiMarkTask.cs b/src/ApiMark.MSBuild/ApiMarkTask.cs index 25a6944..6a8bc7e 100644 --- a/src/ApiMark.MSBuild/ApiMarkTask.cs +++ b/src/ApiMark.MSBuild/ApiMarkTask.cs @@ -619,7 +619,7 @@ protected virtual bool RunToolProcess(string dotnetExe, IReadOnlyList to /// The full path to the dotnet executable, or null if it cannot be found in /// either DOTNET_HOST_PATH or PATH. /// - private static string? ResolveDotNetExe() + protected virtual string? ResolveDotNetExe() { // DOTNET_HOST_PATH is set by the .NET SDK on all platforms and points directly to the // dotnet host executable; prefer it over PATH for reliability in build environments diff --git a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs index 970a06f..55fbcb2 100644 --- a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs +++ b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs @@ -243,8 +243,10 @@ public void ApiMarkTask_Execute_WithDotNetProject_GeneratesDocumentation() // Act: execute the task — this spawns the real ApiMark.Tool child process var result = task.Execute(); - // Assert: task returns true and api.md is written to the output directory + // Assert: task returns true, stdout was forwarded as informational messages, and + // api.md is written to the output directory Assert.True(result); + buildEngine.Received().LogMessageEvent(Arg.Any()); Assert.True( File.Exists(Path.Join(outputDir, "api.md")), "Expected api.md to be created in the output directory."); @@ -653,6 +655,104 @@ public void ApiMarkTask_Execute_ToolExitsNonZero_ReturnsFalseAndLogsError() buildEngine.Received().LogErrorEvent(Arg.Any()); } + /// + /// Validates that returns false and logs a + /// MSBuild error when ResolveDotNetExe cannot locate the dotnet executable. + /// + [Fact] + public void ApiMarkTask_Execute_DotNetExeNotResolved_ReturnsFalseAndLogsError() + { + // Arrange: task subclass that cannot find dotnet; use the test assembly as ToolDllPath + // so the File.Exists check passes and execution reaches the dotnet resolution check + var buildEngine = Substitute.For(); + var task = new NoDotNetApiMarkTask + { + BuildEngine = buildEngine, + ProjectExtension = ".csproj", + ToolDllPath = typeof(ApiMarkTaskTests).Assembly.Location, + ApiMarkAssemblyPath = "test.dll", + ApiMarkXmlDocPath = "test.xml", + }; + + // Act + var result = task.Execute(); + + // Assert: Execute must return false and log an error about the missing dotnet executable + Assert.False(result); + buildEngine.Received().LogErrorEvent(Arg.Any()); + } + + /// + /// Validates that forwards standard error output from the + /// spawned tool to the MSBuild build log as error messages when the tool fails. + /// + [Fact] + public void ApiMarkTask_Execute_ToolWritesToStderr_ForwardsToMsBuildErrors() + { + // Arrange: run the real ApiMark.Tool with a non-existent assembly so it fails and + // writes diagnostic output to stderr, exercising the ErrorDataReceived forwarding path + var testDir = Path.GetDirectoryName(typeof(ApiMarkTaskTests).Assembly.Location)!; + var toolDllPath = Path.Join(testDir, "ApiMark.Tool.dll"); + + var buildEngine = Substitute.For(); + var task = new ApiMarkTask + { + BuildEngine = buildEngine, + ToolDllPath = toolDllPath, + ProjectExtension = ".csproj", + ApiMarkAssemblyPath = Path.Join(Path.GetTempPath(), "nonexistent-for-apimark-test.dll"), + ApiMarkXmlDocPath = Path.Join(Path.GetTempPath(), "nonexistent-for-apimark-test.xml"), + ApiMarkOutputDir = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString()), + }; + + // Act: execute with invalid inputs to cause the tool to fail with stderr/error output + var result = task.Execute(); + + // Assert: Execute must return false and errors must be forwarded to the MSBuild log + Assert.False(result); + buildEngine.Received().LogErrorEvent(Arg.Any()); + } + + /// + /// Validates that spawns one child process per item + /// when is non-empty, confirming that the + /// multi-output path in ExecuteAllOutputs is exercised correctly. + /// + [Fact] + public void ApiMarkTask_Execute_WithMultipleOutputs_RunsToolForEachOutput() + { + // Arrange: two output items with distinct output directories + var buildEngine = Substitute.For(); + + var output1 = Substitute.For(); + output1.GetMetadata("OutputDir").Returns("/output/one"); + output1.GetMetadata("Visibility").Returns(string.Empty); + output1.GetMetadata("Format").Returns(string.Empty); + + var output2 = Substitute.For(); + output2.GetMetadata("OutputDir").Returns("/output/two"); + output2.GetMetadata("Visibility").Returns(string.Empty); + output2.GetMetadata("Format").Returns(string.Empty); + + var task = new RecordingApiMarkTask + { + BuildEngine = buildEngine, + ProjectExtension = ".csproj", + ToolDllPath = typeof(ApiMarkTaskTests).Assembly.Location, + ApiMarkAssemblyPath = "test.dll", + ApiMarkXmlDocPath = "test.xml", + ApiMarkOutputs = [output1, output2], + }; + + // Act + var result = task.Execute(); + + // Assert: exactly one RunToolProcess call per output item, with no errors + Assert.True(result); + Assert.Equal(2, task.RunToolProcessCallCount); + buildEngine.DidNotReceive().LogErrorEvent(Arg.Any()); + } + /// /// Subclass of that overrides RunToolProcess to simulate /// a non-zero tool exit without spawning a real child process. @@ -672,4 +772,35 @@ protected override bool RunToolProcess(string dotnetExe, IReadOnlyList t return false; } } + + /// + /// Subclass of that overrides ResolveDotNetExe to + /// simulate an environment where the dotnet executable cannot be found. + /// + private sealed class NoDotNetApiMarkTask : ApiMarkTask + { + /// + protected override string? ResolveDotNetExe() => null; + } + + /// + /// Subclass of that overrides ResolveDotNetExe and + /// RunToolProcess to record how many times the tool process is spawned without + /// launching a real child process. + /// + private sealed class RecordingApiMarkTask : ApiMarkTask + { + /// Gets the number of times RunToolProcess has been called. + public int RunToolProcessCallCount { get; private set; } + + /// + protected override string? ResolveDotNetExe() => "dummy-dotnet"; + + /// + protected override bool RunToolProcess(string dotnetExe, IReadOnlyList toolArgs) + { + RunToolProcessCallCount++; + return true; + } + } } From 29443fbfaf1760e2795c456bef6bca54a3e24218 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 20:30:40 -0400 Subject: [PATCH 29/62] fix: MSBuild R2 fixes - split requirements, add tests, fix naming - Make ResolveDotNetExe protected virtual; add NoDotNetApiMarkTask subclass - Split ResolveDotNetExecutable into ResolveDotNetFromEnvironment + ReportMissingDotNetExe - Add ApiMarkTask_Execute_DotNetExeNotResolved_ReturnsFalseAndLogsError test - Add ForwardStderrErrors requirement + ApiMarkTask_Execute_ToolWritesToStderr test - Add ForwardStdoutToLog test evidence via end-to-end test assertion - Add RecordingApiMarkTask + multi-output execution test - Fix ApiMarkOutput (item group) vs ApiMarkOutputs (task property) naming in docs - Rewrite ForwardApiHeaders title as single observable criterion; move order/verbatim detail to justification - Update verification docs with new acceptance criteria and scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/api-mark-msbuild/api-mark-task.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml index 6829d0e..35127cc 100644 --- a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml +++ b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml @@ -140,14 +140,15 @@ sections: tests: [ApiMarkTask_DotNet_EmptyXmlDocPath_SkipsExecution] - id: ApiMarkMsbuild-ApiMarkTask-ForwardApiHeaders title: >- - ApiMarkTask shall forward each entry of ApiMarkApiHeaders to the tool - as a separate - --api-headers flag for C++ builds, preserving order and forwarding '!'-prefixed - exclusion patterns verbatim. + ApiMarkTask shall forward the ApiMarkApiHeaders collection to the tool + as individual --api-headers flags for C++ builds. justification: | C++ projects need fine-grained, gitignore-style control over which headers contribute - to the documented API surface. Forwarding each pattern as its own --api-headers flag - preserves order and lets CppGenerator apply last-match-wins semantics correctly. + to the documented API surface. Each pattern must be forwarded as its own --api-headers + flag so CppGenerator can apply last-match-wins gitignore semantics correctly. Order + must be preserved because later patterns override earlier ones. Exclusion patterns + prefixed with '!' must be forwarded verbatim so CppGenerator, not MSBuild, interprets + them. tests: [ApiMarkTask_Cpp_ApiHeaders_ForwardedAsIndividualFlags] - id: ApiMarkMsbuild-ApiMarkTask-ForwardFormatProperty title: ApiMarkTask shall forward the ApiMarkFormat property to the tool From 3cc440917f58d8b8859caf4fc609a7341aef78d6 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 20:36:03 -0400 Subject: [PATCH 30/62] fix: MSBuild R4 fixes - requirement titles and missing PackageTests folder - Rewrite OverrideOutputProperties/RestoreOutputProperties in observable-behavior terms - Fix ApiMarkOutputs (task property) vs ApiMarkOutput (item group) in design doc - Add test/ApiMark.MSBuild.PackageTests/ to design and verification introduction listings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-msbuild.md | 8 +++---- docs/design/introduction.md | 3 +-- .../api-mark-msbuild/api-mark-task.yaml | 21 ++++++++++--------- docs/verification/introduction.md | 8 +++---- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/design/api-mark-msbuild.md b/docs/design/api-mark-msbuild.md index 5ae5973..5fc7126 100644 --- a/docs/design/api-mark-msbuild.md +++ b/docs/design/api-mark-msbuild.md @@ -57,10 +57,10 @@ task. `ApiMarkCppStandard` (e.g. `c++17`; optional, defaulted to `c++17` by the `.targets` file when not set by the project), `ApiMarkClangPath` (optional explicit clang executable path). - Optional input item group `ApiMarkOutputs`; each item carries `OutputDir`, `Format`, and - `Visibility` metadata that overrides the corresponding scalar properties for that named output - configuration; one child process is spawned per item; documentation packaging is handled by - `_ApiMarkIncludeDocsInPackage`. + Optional input item group `ApiMarkOutput` (exposed to MSBuild projects via the task property + `ApiMarkOutputs`); each item carries `OutputDir`, `Format`, and `Visibility` metadata that + overrides the corresponding scalar properties for that named output configuration; one child + process is spawned per item; documentation packaging is handled by `_ApiMarkIncludeDocsInPackage`. Fires `AfterTargets="Build"` unless `DisableApiMark` is true. Language is inferred from `ProjectExtension` when `ApiMarkLanguage` is not explicitly set. - *Constraints*: Must not load any language-generator libraries in the MSBuild diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 8963743..de5f7f4 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -205,12 +205,11 @@ Each local software item has corresponding artifacts in parallel directory trees `src/ApiMark.MSBuild/`, `src/ApiMark.Tool/` - Tests: `test/ApiMark.Core.TestHelpers/`, `test/ApiMark.Core.Tests/`, `test/ApiMark.DotNet.Tests/`, `test/ApiMark.Cpp.Tests/`, `test/ApiMark.Vhdl.Tests/`, - `test/ApiMark.MSBuild.Tests/`, `test/ApiMark.Tool.Tests/` + `test/ApiMark.MSBuild.Tests/`, `test/ApiMark.MSBuild.PackageTests/`, `test/ApiMark.Tool.Tests/` - Fixtures: `test/ApiMark.DotNet.Fixtures/`, `test/ApiMark.Cpp.Fixtures/` OTS items have integration/usage design documentation parallel to system folders: -- Requirements: `docs/reqstream/ots/mono-cecil.yaml` - Design: `docs/design/ots/mono-cecil.md` - Verification: `docs/verification/ots/mono-cecil.md` diff --git a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml index 35127cc..d14bb9b 100644 --- a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml +++ b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml @@ -174,22 +174,23 @@ sections: - ApiMarkTask_Execute_WithMultipleOutputs_RunsToolForEachOutput - id: ApiMarkMsbuild-ApiMarkTask-OverrideOutputProperties title: >- - ApiMarkTask shall override OutputDir, Visibility, and Format from each - ApiMarkOutputs item's metadata when building arguments for that output. + Each ApiMarkOutput item shall use its own OutputDir, Visibility, and Format + metadata as the effective values for its tool invocation, independently of the + task-level properties. justification: | - Each named output in the ApiMarkOutputs ItemGroup must be able to specify its own - output directory, visibility level, and format independently so that a single build - invocation can produce multiple distinct documentation artifacts. + A single build invocation must be able to produce multiple distinct documentation + artifacts, each with its own output directory, visibility level, and format. Item-level + metadata takes precedence over the task-level property values for that item's invocation. tests: - ApiMarkTask_BuildArgumentsForOutput_OverridesScalarPropertiesFromMetadata - id: ApiMarkMsbuild-ApiMarkTask-RestoreOutputProperties title: >- - ApiMarkTask shall restore the original OutputDir, Visibility, and Format - values after building arguments for each output item. + Property overrides applied for one ApiMarkOutput item shall not affect the + tool invocation arguments of any subsequent item. justification: | - Scalar property values must be restored after each per-output invocation so that - subsequent items and the scalar invocation path continue to use the original - project-level property values, avoiding cross-contamination between outputs. + Each output item must be processed with its own metadata in isolation. If one item's + overrides persisted into the next item's invocation, outputs would receive incorrect + arguments, producing wrong documentation artifacts. tests: - ApiMarkTask_BuildArgumentsForOutput_RestoresScalarPropertiesAfterCall - id: ApiMarkMsbuild-ApiMarkTask-ResolveDotNetFromEnvironment diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index b52cb9d..6b5e5db 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -95,6 +95,7 @@ test/ ├── ApiMark.Cpp.Tests/ - unit tests for CppGenerator ├── ApiMark.Vhdl.Tests/ - unit tests for VhdlGenerator ├── ApiMark.MSBuild.Tests/ - unit tests for ApiMarkTask +├── ApiMark.MSBuild.PackageTests/ - integration tests for the MSBuild NuGet package ├── ApiMark.Tool.Tests/ - integration tests for the CLI tool ├── ApiMark.DotNet.Fixtures/ - multi-target fixture assembly for DotNet integration tests └── ApiMark.Cpp.Fixtures/ - C++ fixture headers for CppGenerator integration tests @@ -240,14 +241,13 @@ Local items have parallel artifacts in: `src/ApiMark.Vhdl/`, `src/ApiMark.MSBuild/`, `src/ApiMark.Tool/` - Tests: `test/ApiMark.Core.TestHelpers/`, `test/ApiMark.Core.Tests/`, `test/ApiMark.Cpp.Tests/`, `test/ApiMark.DotNet.Tests/`, `test/ApiMark.Vhdl.Tests/`, - `test/ApiMark.MSBuild.Tests/`, `test/ApiMark.Tool.Tests/` + `test/ApiMark.MSBuild.Tests/`, `test/ApiMark.MSBuild.PackageTests/`, `test/ApiMark.Tool.Tests/` - Fixtures: `test/ApiMark.DotNet.Fixtures/`, `test/ApiMark.Cpp.Fixtures/` -OTS items have integration and usage artifacts parallel to the system folders: - -- Requirements: `docs/reqstream/ots/clang.yaml`, `docs/reqstream/ots/mono-cecil.yaml`, +OTS items have integration and usage artifacts parallel to the system folders: `docs/reqstream/ots/mono-cecil.yaml`, `docs/reqstream/ots/dema-consulting-test-results.yaml`, `docs/reqstream/ots/antlr4.yaml`, `docs/reqstream/ots/file-system-globbing.yaml`, `docs/reqstream/ots/cpp-ast-net.yaml` + - Design: `docs/design/ots/clang.md`, `docs/design/ots/mono-cecil.md`, `docs/design/ots/dema-consulting-test-results.md`, `docs/design/ots/antlr4.md`, `docs/design/ots/file-system-globbing.md`, `docs/design/ots/cpp-ast-net.md` From 21dd6a3422d29a81d7865ff19658255d5d195698 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 20:42:53 -0400 Subject: [PATCH 31/62] fix: MSBuild R5 fixes - remove version strings, add verification scenarios - Replace net8.0/netstandard2.0 references in design doc with framework-agnostic descriptions - Replace c++17 default example with general description in design doc - Replace vhdl2008 grammar name with ANTLR4 VHDL grammar in introduction.md - Add missing verification acceptance criteria: ApiMarkFormat, ApiMarkLibraryDescription, ApiMarkClangPath, EmptyXmlDocPath skip - Add missing test scenarios for all above plus Format not-forwarded case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-msbuild.md | 24 ++++++++++++------------ docs/design/introduction.md | 6 +++--- docs/verification/api-mark-msbuild.md | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/design/api-mark-msbuild.md b/docs/design/api-mark-msbuild.md index 5fc7126..ad0dfef 100644 --- a/docs/design/api-mark-msbuild.md +++ b/docs/design/api-mark-msbuild.md @@ -13,14 +13,14 @@ Visual Studio C++ builds (Windows). Rather than calling language generators in-process, ApiMarkTask spawns the `ApiMark.Tool` .NET executable as a child process, passing all configuration as command-line arguments. This out-of-process design allows the language generators -to target `net8.0` freely — using libraries that do not support `netstandard2.0` -such as newer versions of CppAst.Net — while the MSBuild task itself targets -`netstandard2.0` for full compatibility with both .NET Framework MSBuild (Visual -Studio) and the .NET SDK MSBuild (`dotnet build`). +to target a modern .NET framework freely — using libraries that do not support the +MSBuild-compatible .NET Standard target such as newer versions of CppAst.Net — while +the MSBuild task itself targets a .NET Standard-compatible target for full compatibility +with both .NET Framework MSBuild (Visual Studio) and the .NET SDK MSBuild (`dotnet build`). The NuGet package `ApiMark.MSBuild` bundles both the task assembly and the -pre-compiled `ApiMark.Tool` DLL and its dependencies under `tools/net8.0/`. The -`.targets` file wires `ApiMarkTask` into `AfterTargets="Build"` and sets the +pre-compiled `ApiMark.Tool` DLL and its dependencies under a `tools/` subfolder. +The `.targets` file wires `ApiMarkTask` into `AfterTargets="Build"` and sets the `ToolDllPath` property to the bundled tool location. When `ApiMarkPackDocs` is set to `true`, the `.targets` file also hooks the @@ -34,7 +34,7 @@ project explicitly opts in. **MSBuild task (provided)**: ApiMarkTask is consumed by MSBuild as an in-process task. -- *Type*: MSBuild task (`netstandard2.0`, in-process in the MSBuild host). +- *Type*: MSBuild task (.NET Standard-compatible target, in-process in the MSBuild host). - *Role*: Provider — `.csproj` and `.vcxproj` files reference the task via the NuGet package; MSBuild invokes the task at build time. - *Contract*: MSBuild properties `ApiMarkLanguage`, `ApiMarkOutputDir`, @@ -54,8 +54,8 @@ task. (semicolon-delimited ordered glob/exclusion patterns; optional), `ApiMarkDefines` (semicolon-delimited preprocessor defines; semicolons converted to commas for the tool argument), - `ApiMarkCppStandard` (e.g. `c++17`; optional, defaulted to `c++17` by the `.targets` file - when not set by the project), + `ApiMarkCppStandard` (a C++ language standard identifier; optional, defaulted to a + recent C++ standard by the `.targets` file when not set by the project), `ApiMarkClangPath` (optional explicit clang executable path). Optional input item group `ApiMarkOutput` (exposed to MSBuild projects via the task property `ApiMarkOutputs`); each item carries `OutputDir`, `Format`, and `Visibility` metadata that @@ -130,10 +130,10 @@ bundled in the NuGet package. ## Design Constraints -- Task platform: targets `netstandard2.0` so the same assembly runs in .NET +- Task platform: targets a .NET Standard-compatible target so the same assembly runs in .NET Framework MSBuild (Visual Studio) and .NET SDK MSBuild (`dotnet build`). -- Tool platform: `ApiMark.Tool.dll` targets `net8.0`; language generators are free - to use any library regardless of `netstandard` support. +- Tool platform: `ApiMark.Tool.dll` targets a modern .NET framework; language generators are free + to use any library regardless of .NET Standard support. - Cross-platform `dotnet` resolution: `DOTNET_HOST_PATH` is set by the SDK on all platforms; the task must fall back to searching `PATH` for environments where it is not set (e.g., Visual Studio on .NET Framework MSBuild). diff --git a/docs/design/introduction.md b/docs/design/introduction.md index de5f7f4..8d94461 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -12,7 +12,7 @@ systems: ApiMark.Core (shared contracts and file-path helpers), ApiMark.DotNet .NET executable invoked by ApiMarkTask and directly by users or CI pipelines). Several OTS items provide library support: Mono.Cecil for the DotNet system, clang (via `clang -ast-dump=json`) for the Cpp system, DemaConsulting.TestResults for the SelfTest -subsystem, Antlr4.Runtime.Standard/ANTLR4 vhdl2008 grammar for the Vhdl system, and +subsystem, Antlr4.Runtime.Standard with an ANTLR4 VHDL grammar for the Vhdl system, and Microsoft.Extensions.FileSystemGlobbing for glob-based file discovery in ApiMarkCore. An archived OTS item, cpp-ast-net, is retained for historical reference. @@ -33,7 +33,7 @@ OTS items: - **Mono.Cecil**: integration and usage design. - **clang**: integration and usage design (via `clang -ast-dump=json`). - **DemaConsulting.TestResults**: integration and usage design. -- **Antlr4.Runtime.Standard / ANTLR4 vhdl2008 grammar**: integration and usage design. +- **Antlr4.Runtime.Standard / ANTLR4 VHDL grammar**: integration and usage design. - **Microsoft.Extensions.FileSystemGlobbing**: integration and usage design. - **cpp-ast-net**: integration and usage design (archived; retained for historical reference). @@ -96,7 +96,7 @@ OTS Dependencies: ├── Mono.Cecil (OTS) ├── DemaConsulting.TestResults (OTS) ├── clang -ast-dump=json (OTS) -├── Antlr4.Runtime.Standard / ANTLR4 vhdl2008 grammar (OTS) +├── Antlr4.Runtime.Standard / ANTLR4 VHDL grammar (OTS) ├── Microsoft.Extensions.FileSystemGlobbing (OTS) └── cpp-ast-net (OTS) [archived] ``` diff --git a/docs/verification/api-mark-msbuild.md b/docs/verification/api-mark-msbuild.md index afe98da..02069a1 100644 --- a/docs/verification/api-mark-msbuild.md +++ b/docs/verification/api-mark-msbuild.md @@ -26,8 +26,13 @@ permission is required. - Language inference from project extension selects `cpp` for `.vcxproj` and `dotnet` for `.csproj` when `ApiMarkLanguage` is not explicitly set. - `ApiMarkOutputDir` and `ApiMarkVisibility` are forwarded correctly to the tool. +- `ApiMarkFormat` is forwarded as the `--format` argument when set; it is omitted when not set. +- For C++ projects, `ApiMarkLibraryDescription` is forwarded as `--library-description` when set. +- For C++ projects, `ApiMarkClangPath` is forwarded as `--clang-path` when set. - `ApiMarkIncludeObsolete` set to `true` adds the `--include-obsolete` flag to the spawned tool command. +- When `ApiMarkXmlDocPath` is empty or unset for a .NET project, the task returns success + immediately without invoking the tool. - A non-zero exit code from the spawned tool causes Execute to return false and log an MSBuild error. - When `ApiMarkPackDocs` is `true`, the generated `api/` folder is included in the NuGet package; when `false` or unset, the `api/` folder is not packaged. @@ -117,6 +122,25 @@ This scenario is tested by set to `true`, the `--include-obsolete` flag is added to the spawned tool command. This scenario is tested by `ApiMarkTask_IncludeObsolete_True_ForwardsIncludeObsoleteFlag`. +**Format property is forwarded when set and omitted when unset**: Verifies that the +`ApiMarkFormat` property is passed to the spawned tool as the `--format` argument when it +has a value, and that the argument is omitted entirely when `ApiMarkFormat` is not set. These +scenarios are tested by `ApiMarkTask_Format_ForwardedToToolAsFormatArgument` and +`ApiMarkTask_Format_NotForwarded_WhenNotSet`. + +**C++ library description is forwarded to the tool**: Verifies that the `ApiMarkLibraryDescription` +property is passed to the spawned tool as the `--library-description` argument for C++ builds. +This scenario is tested by `ApiMarkTask_Cpp_LibraryDescription_ForwardedToTool`. + +**C++ clang path is forwarded to the tool**: Verifies that the `ApiMarkClangPath` property is +passed to the spawned tool as the `--clang-path` argument for C++ builds when set. This scenario +is tested by `ApiMarkTask_Cpp_ClangPath_ForwardedToTool`. + +**Empty XmlDocPath causes graceful skip for .NET project**: Verifies that when +`ApiMarkXmlDocPath` is empty or unset for a .NET project, the task returns success immediately +with no tool invocation. This scenario is tested by +`ApiMarkTask_DotNet_EmptyXmlDocPath_SkipsExecution`. + **Multiple outputs spawn one tool process per item**: Verifies that when `ApiMarkOutputs` is non-empty, `Execute` spawns one child process per item in the `ApiMarkOutput` item group, applying per-item metadata overrides for `OutputDir`, `Format`, and `Visibility` so that From 74520778b1328f28b5e834a990d169e6ad1377d1 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 20:46:51 -0400 Subject: [PATCH 32/62] fix: MSBuild R6 fixes - auto-populate scenario and OS requirements - Add acceptance criterion for AutoPopulateIncludePaths (Windows-only) - Add test scenario for C++ include path auto-population - Add OS requirements section to verification Test Environment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/verification/api-mark-msbuild.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/verification/api-mark-msbuild.md b/docs/verification/api-mark-msbuild.md index 02069a1..dbe6ab3 100644 --- a/docs/verification/api-mark-msbuild.md +++ b/docs/verification/api-mark-msbuild.md @@ -18,6 +18,11 @@ package integration tests additionally require the pre-built `DemaConsulting.Api have not run `dotnet pack` are not blocked. No external service dependency or elevated permission is required. +OS requirements: unit tests run on Windows, Linux, and macOS. NuGet package integration +tests for `.csproj` projects run cross-platform. NuGet package integration tests for +`.vcxproj` C++ projects are Windows-only (MSBuild C++ toolchain required) and are skipped +on non-Windows environments. + ## Acceptance Criteria - All ApiMark.MSBuild integration tests pass with zero failures. @@ -36,6 +41,8 @@ permission is required. - A non-zero exit code from the spawned tool causes Execute to return false and log an MSBuild error. - When `ApiMarkPackDocs` is `true`, the generated `api/` folder is included in the NuGet package; when `false` or unset, the `api/` folder is not packaged. +- For C++ projects, when `ApiMarkIncludePaths` is not explicitly set, the task automatically + populates it from `AdditionalIncludeDirectories` metadata on `ClCompile` items (Windows only). - For C++ projects, `ApiMarkLibraryName` is forwarded as `--library-name` when set. - For C++ projects, `ApiMarkDefines` semicolons are converted to commas when forwarding as `--defines`. @@ -105,6 +112,12 @@ can apply last-match-wins gitignore semantics. This scenario is tested by side effects and no tool invocation. This scenario is tested by `ApiMarkTask_Cpp_EmptyIncludePaths_SkipsExecution`. +**C++ include paths auto-populated from ClCompile metadata (Windows)**: Verifies that when +`ApiMarkIncludePaths` is not explicitly set, the task harvests `AdditionalIncludeDirectories` +from `ClCompile` items and uses those paths as the include root for the tool invocation. This +scenario is exercised by `ApiMarkMsbuild_NuGetPackage_CppVcxprojProject_AutoDocumentsOnBuild` +on Windows. + **DotNet project executes tool and generates documentation**: End-to-end integration test that exercises the complete .NET documentation generation path — locates the bundled `ApiMark.Tool.dll`, spawns it against a real fixture assembly, and verifies that `api.md` is produced in the output From 371d32e0e4e72ca3ea0d2bd792f8dd8311f2ad42 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:07:02 -0400 Subject: [PATCH 33/62] docs/tests: apply multi-fix cleanup (Fixes 1-7) --- docs/design/api-mark-tool.md | 12 ++-- docs/design/api-mark-tool/cli/context.md | 9 +++ docs/design/api-mark-tool/program.md | 4 +- docs/reqstream/api-mark-tool/cli.yaml | 23 +++++++ docs/reqstream/api-mark-tool/cli/context.yaml | 44 ++++++++---- docs/verification/api-mark-tool/cli.md | 16 +++++ .../verification/api-mark-tool/cli/context.md | 14 ++++ src/ApiMark.Tool/Program.cs | 2 +- test/ApiMark.Tool.Tests/Cli/ContextTests.cs | 68 +++++++++++++++++++ test/ApiMark.Tool.Tests/ConsoleCollection.cs | 15 ++++ test/ApiMark.Tool.Tests/ProgramTests.cs | 1 + 11 files changed, 185 insertions(+), 23 deletions(-) create mode 100644 test/ApiMark.Tool.Tests/ConsoleCollection.cs diff --git a/docs/design/api-mark-tool.md b/docs/design/api-mark-tool.md index 28e26c8..8779068 100644 --- a/docs/design/api-mark-tool.md +++ b/docs/design/api-mark-tool.md @@ -7,8 +7,8 @@ ApiMarkTool is organized into two subsystems (`Cli` and `SelfTest`) and one top-level unit (`Program`). It is the .NET executable (`ApiMark.Tool.dll`) distributed as part of -the NuGet package `ApiMark.MSBuild` (under `tools/net8.0/`, `tools/net9.0/`, and -`tools/net10.0/`) and optionally as a standalone dotnet tool in the NuGet package +the NuGet package `ApiMark.MSBuild` (under the appropriate framework-specific tool +folders) and optionally as a standalone dotnet tool in the NuGet package `DemaConsulting.ApiMark.Tool`. - `Cli` subsystem — provides the `Context` unit, which parses command-line arguments into @@ -21,7 +21,7 @@ the NuGet package `ApiMark.MSBuild` (under `tools/net8.0/`, `tools/net9.0/`, and `Parse()` to obtain an `IApiEmitter`, then calls `IApiEmitter.Emit()` with an `EmitConfig` built from the parsed context. -ApiMarkTool targets `net8.0;net9.0;net10.0` (multi-target). It is packaged as a +ApiMarkTool targets multiple modern .NET framework versions (multi-target). It is packaged as a dotnet tool (`PackAsTool=true`, `ToolCommandName=apimark`). Because it runs as a standalone process rather than inside the MSBuild host, language generators may depend on any .NET library regardless of `netstandard2.0` support requirements. @@ -106,13 +106,13 @@ N/A - not a safety-classified software item. ## Design Constraints -- Platform: targets `net8.0;net9.0;net10.0`; runs on Linux, macOS, and Windows +- Platform: targets multiple modern .NET framework versions; runs on Linux, macOS, and Windows via `dotnet` or as an installed dotnet tool. - Language extensibility: new language subcommands are added by implementing `IApiGenerator` and adding a dispatch branch; no changes to Core are required. - No direct assembly reflection: all assembly reading is delegated to the appropriate `IApiGenerator` implementation; Program never calls Mono.Cecil or CppAst.Net directly. -- NuGet packaging: bundled inside `ApiMark.MSBuild` under `tools/net8.0/`, - `tools/net9.0/`, and `tools/net10.0/`; also published separately as +- NuGet packaging: bundled inside `ApiMark.MSBuild` under the appropriate framework-specific + tool folders; also published separately as `DemaConsulting.ApiMark.Tool` for direct CLI use. diff --git a/docs/design/api-mark-tool/cli/context.md b/docs/design/api-mark-tool/cli/context.md index 71cf492..79c1447 100644 --- a/docs/design/api-mark-tool/cli/context.md +++ b/docs/design/api-mark-tool/cli/context.md @@ -72,6 +72,15 @@ construction path. - *Exceptions*: `ArgumentException` on unknown flag or missing required value; `InvalidOperationException` if the log file cannot be opened. +**`--depth` range and format constraints**: `--depth` accepts integer values +in the range 1–6 for the `gradual` output format. For the `single-file` output +format, `--depth` is additionally restricted to 1–3 because member-level headings +in single-file output are rendered at `depth+3`; a depth value greater than 3 +would produce heading levels beyond H6, which is the maximum valid ATX heading +level in Markdown and cannot be rendered meaningfully by Markdown processors. +The constraint is enforced during argument parsing regardless of which order +`--depth` and `--format single-file` appear in the argument list. + **Context.WriteLine(string message)** — Writes a line to stdout and to the log file. diff --git a/docs/design/api-mark-tool/program.md b/docs/design/api-mark-tool/program.md index 90ca1a4..b53db5f 100644 --- a/docs/design/api-mark-tool/program.md +++ b/docs/design/api-mark-tool/program.md @@ -19,8 +19,8 @@ construction, and help/banner printing. **Program.Version** (public static `string` property): Returns the informational version string read from `AssemblyInformationalVersionAttribute` via reflection, with -fallback to `AssemblyVersion`, then `"0.0.0"`. Used by `PrintBanner` and `Run` when -responding to `--version`. +fallback to `AssemblyVersion`, then a safe placeholder string. Used by `PrintBanner` +and `Run` when responding to `--version`. **Context** (`Cli/Context.cs`): See _Cli Subsystem Design_ (`cli.md`) and _Context Unit Design_ (`cli/context.md`) for the full data model and interface. diff --git a/docs/reqstream/api-mark-tool/cli.yaml b/docs/reqstream/api-mark-tool/cli.yaml index ac50e83..b849efe 100644 --- a/docs/reqstream/api-mark-tool/cli.yaml +++ b/docs/reqstream/api-mark-tool/cli.yaml @@ -25,4 +25,27 @@ sections: - ApiMarkTool-Cli-Context-ParseFormatOption - ApiMarkTool-Cli-Context-ParseClangPath - ApiMarkTool-Cli-Context-ParseVhdlSourceOptions + - ApiMarkTool-Cli-Context-ValidateDepthForSingleFileFormat + - ApiMarkTool-Cli-SuppressOutputWhenSilent + - ApiMarkTool-Cli-RouteOutputThroughContext tests: [Context_Cli_ParsesAllGlobalFlags] + - id: ApiMarkTool-Cli-SuppressOutputWhenSilent + title: The Cli shall suppress all console output (both stdout and stderr) when + the --silent flag is specified. + justification: | + Silent mode is used in scripted or automated invocations where console output + would pollute log output. WriteError still sets the exit code but does not print + to the console. + tests: + - Context_Create_WithSilentFlag_SetsSilentTrue + - id: ApiMarkTool-Cli-RouteOutputThroughContext + title: The Cli shall route all tool output through the Context.WriteLine and + Context.WriteError methods so that output can be consistently captured, + suppressed, or redirected to a log file. + justification: | + Centralizing output through Context ensures that --silent and --log work + uniformly regardless of which component produces the output. + tests: + - Context_Create_WithLogFile_OpensAndWritesToLog + - Context_OpenLogFile_ErrorOutputAlsoWrittenToLog + - Program_Main_WithSilentAndLog_DotNetCommand_ExitsZero diff --git a/docs/reqstream/api-mark-tool/cli/context.yaml b/docs/reqstream/api-mark-tool/cli/context.yaml index 33107f8..f04cf36 100644 --- a/docs/reqstream/api-mark-tool/cli/context.yaml +++ b/docs/reqstream/api-mark-tool/cli/context.yaml @@ -66,13 +66,15 @@ sections: - Context_Create_WithApiHeadersExclusionPattern_ForwardsVerbatim - Context_Create_WithNoArguments_HasEmptyApiHeaders - id: ApiMarkTool-Cli-Context-ParseCppNamedOptions - title: Context shall parse the C++-specific named options --library-name, + title: Context shall parse the named options --library-name, --library-description, --defines, and --cpp-standard into the corresponding - Context properties. + Context properties, where --library-name and --library-description are + shared by the C++ and VHDL subcommands. justification: | - C++ documentation generation requires library metadata and parser configuration - that has no equivalent in the .NET path. Grouping these as a unit gives a single - traceable requirement for the cpp-specific scalar option set. + C++ and VHDL documentation generation share library metadata options + (--library-name and --library-description) while --defines and --cpp-standard + are C++ specific. Grouping these as a unit gives a single traceable requirement + for the cpp-specific and shared scalar option set. children: - ApiMarkTool-Cli-Context-ParseLibraryName - ApiMarkTool-Cli-Context-ParseLibraryDescription @@ -87,15 +89,17 @@ sections: title: Context shall parse the --library-name option and store it in the LibraryName property. justification: | - Program.CreateGenerator passes LibraryName to CppGeneratorOptions so the - generated api.md uses the project-supplied display name rather than a filename. + Program.CreateGenerator passes LibraryName to both CppGeneratorOptions and + VhdlGeneratorOptions so that the generated api.md uses the project-supplied + display name rather than a filename for both C++ and VHDL subcommands. tests: [Context_Create_WithLibraryNameOption_SetsLibraryName] - id: ApiMarkTool-Cli-Context-ParseLibraryDescription title: Context shall parse the --library-description option and store it in the LibraryDescription property. justification: | - Program.CreateGenerator passes LibraryDescription to CppGeneratorOptions so the - generated api.md can include a project-supplied description paragraph. + Program.CreateGenerator passes LibraryDescription to both CppGeneratorOptions + and VhdlGeneratorOptions so the generated api.md can include a project-supplied + description paragraph for both C++ and VHDL subcommands. tests: - Context_Create_WithLibraryDescriptionOption_SetsLibraryDescription - id: ApiMarkTool-Cli-Context-ParseDefines @@ -134,12 +138,14 @@ sections: tests: [Context_Create_WithResultsOption_SetsResultsFile] - id: ApiMarkTool-Cli-Context-OpenLogFileOnRequest title: Context shall open the file specified by --log and mirror all - subsequent output written through WriteLine to that file. + subsequent output written through `WriteLine` and `WriteError` to that file. justification: | - ApiMarkTask invokes ApiMarkTool out-of-process and captures output by - redirecting it to a log file; --log provides a reliable capture path that - works even when stdout is not redirected. - tests: [Context_Create_WithLogFile_OpensAndWritesToLog] + ApiMarkTask invokes ApiMarkTool out-of-process; the log file captures both + normal output and error messages, making it a reliable capture path for both + stdout and stderr even when console redirection is unavailable. + tests: + - Context_Create_WithLogFile_OpensAndWritesToLog + - Context_OpenLogFile_ErrorOutputAlsoWrittenToLog - id: ApiMarkTool-Cli-Context-TrackErrorsViaWriteError title: Context shall set ExitCode to 1 when WriteError is called at least once. @@ -180,3 +186,13 @@ sections: - Context_Create_WithSourceOption_SetsSources - Context_Create_WithRepeatedSource_AccumulatesAllPaths - Context_Create_WithSourceExclusionPattern_ForwardsVerbatim + - id: ApiMarkTool-Cli-Context-ValidateDepthForSingleFileFormat + title: Context shall reject --depth values greater than 3 when --format + single-file is used. + justification: | + In single-file format, member-level headings are rendered at depth+3; a depth + greater than 3 would produce heading levels beyond H6, which is the maximum + valid Markdown heading level and cannot be rendered meaningfully. + tests: + - Context_Create_WithDepthAbove3AndSingleFileFormat_ThrowsArgumentException + - Context_Create_WithDepth3AndSingleFileFormat_Succeeds diff --git a/docs/verification/api-mark-tool/cli.md b/docs/verification/api-mark-tool/cli.md index 8ef817f..0b69795 100644 --- a/docs/verification/api-mark-tool/cli.md +++ b/docs/verification/api-mark-tool/cli.md @@ -21,6 +21,10 @@ Standard .NET test runner. No external files are required except for the log-fil - The subsystem-level integration test `Context_Cli_ParsesAllGlobalFlags` passes, confirming that all global flags (`--version`, `--help`, `--silent`, `--validate`) can be supplied simultaneously and all corresponding properties are set correctly. +- When `--silent` is specified, no output is written to stdout or stderr; `ExitCode` + still reflects error state when `WriteError` is called. +- All tool output is routed through `Context.WriteLine` or `Context.WriteError`; both + methods write unconditionally to any open log file regardless of the `--silent` flag. ### Test Scenarios @@ -28,5 +32,17 @@ Standard .NET test runner. No external files are required except for the log-fil and `--validate` can all be supplied in a single argument array and all corresponding properties are set. Tested by `Context_Cli_ParsesAllGlobalFlags`. +**Silent flag suppresses console output**: Verifies that `--silent` sets the `Silent` +property to `true`. Tested by `Context_Create_WithSilentFlag_SetsSilentTrue`. + +**Output routed through Context to log file**: Verifies that both `WriteLine` and +`WriteError` output is captured in the log file when `--log` is supplied. +Tested by `Context_Create_WithLogFile_OpensAndWritesToLog` and +`Context_OpenLogFile_ErrorOutputAlsoWrittenToLog`. + +**Silent with log captures output**: Verifies that `--silent --log` combination allows +the tool to run silently while still capturing all output. Tested by +`Program_Main_WithSilentAndLog_DotNetCommand_ExitsZero`. + For detailed unit-level test scenarios covering individual flags, options, default values, error conditions, and log file behavior, see the Context Unit Verification. diff --git a/docs/verification/api-mark-tool/cli/context.md b/docs/verification/api-mark-tool/cli/context.md index dae1ee9..32f96fc 100644 --- a/docs/verification/api-mark-tool/cli/context.md +++ b/docs/verification/api-mark-tool/cli/context.md @@ -21,7 +21,10 @@ up after itself. No other external files, services, or configuration are require - Unknown flags produce `ArgumentException`. - `WriteError` unconditionally sets `ExitCode` to `1` regardless of the `Silent` flag. - `--log ` creates the file and captures `WriteLine` output. +- `--log ` also captures `WriteError` output. - `--depth ` sets `HeadingDepth` to `n`; values outside 1–6 or non-integers throw `ArgumentException`. +- When `--format single-file` is in effect, `--depth` values above 3 throw `ArgumentException` regardless of argument order. +- `--depth 3` with `--format single-file` is accepted (boundary value). - `--results`/`--result` sets `ResultsFile` to the supplied path. - `--includes` accepts one directory path per flag; repeated flags accumulate paths into `Includes`. - `--api-headers` patterns are accumulated in order; `!`-prefixed exclusion patterns are forwarded verbatim. @@ -144,3 +147,14 @@ corresponding properties set simultaneously. `--source "src/**/*.vhd" --source "!src/tb/**/*.vhd"` → `Sources = ["src/**/*.vhd", "!src/tb/**/*.vhd"]` (the `!` prefix is preserved verbatim so `VhdlGenerator` can apply gitignore semantics). + +**`Context_OpenLogFile_ErrorOutputAlsoWrittenToLog`**: `--log ` + `WriteError` +→ file exists and contains the error message after `Dispose`. + +**`Context_Create_WithDepthAbove3AndSingleFileFormat_ThrowsArgumentException`**: +`--format single-file --depth 4` and `--depth 4 --format single-file` both throw +`ArgumentException` (theory test covering both argument orderings). + +**`Context_Create_WithDepth3AndSingleFileFormat_Succeeds`**: `--format single-file --depth 3` +parses successfully with `HeadingDepth = 3` and `Format = OutputFormat.SingleFile` +(boundary value is accepted). diff --git a/src/ApiMark.Tool/Program.cs b/src/ApiMark.Tool/Program.cs index 17d8c26..4956afa 100644 --- a/src/ApiMark.Tool/Program.cs +++ b/src/ApiMark.Tool/Program.cs @@ -308,7 +308,7 @@ private static void PrintHelp(Context context) context.WriteLine(" -?, -h, --help Display this help message"); context.WriteLine(" --silent Suppress console output"); context.WriteLine(" --validate Run self-validation tests"); - context.WriteLine(" --results Write validation results to file (.trx or .xml)"); + context.WriteLine(" --results, --result Write validation results to file (.trx or .xml)"); context.WriteLine(" --depth <#> Set the top-level heading depth for generated Markdown output (default: 1)"); context.WriteLine(" --format Output format: gradual (default) or single-file"); context.WriteLine(" --log Write all output to log file"); diff --git a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs index 5ecf8d3..806bddf 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -687,4 +687,72 @@ public void Context_Create_WithSourceExclusionPattern_ForwardsVerbatim() // Assert Assert.Equal(["src/**/*.vhd", "!src/tb/**/*.vhd"], context.Sources); } + + /// + /// Validates that output written through is also + /// captured in the log file when --log is specified. + /// + [Fact] + public void Context_OpenLogFile_ErrorOutputAlsoWrittenToLog() + { + // Arrange: create a temporary file path for the log + var tempPath = Path.Join(Path.GetTempPath(), Path.GetRandomFileName() + ".log"); + + try + { + // Act: create context with --log, write an error message, and dispose to flush + using (var context = Context.Create(["--silent", "--log", tempPath])) + { + context.WriteError("test error output"); + } + + // Assert: the log file must exist and contain the error message + Assert.True(File.Exists(tempPath), "Log file must be created"); + var content = File.ReadAllText(tempPath); + Assert.Contains("test error output", content); + } + finally + { + // Clean up the temporary log file regardless of test outcome + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + /// + /// Validates that --depth values above 3 throw + /// when --format single-file is specified, regardless of argument order. + /// + [Theory] + [InlineData("--format", "single-file", "--depth", "4")] + [InlineData("--depth", "4", "--format", "single-file")] + public void Context_Create_WithDepthAbove3AndSingleFileFormat_ThrowsArgumentException( + string arg1, string val1, string arg2, string val2) + { + // Arrange: build the argument array from the theory parameters + var args = new[] { arg1, val1, arg2, val2 }; + + // Act / Assert: depth > 3 with single-file format must throw ArgumentException + Assert.Throws(() => Context.Create(args)); + } + + /// + /// Validates that --depth 3 with --format single-file is accepted + /// (boundary value must not be rejected). + /// + [Fact] + public void Context_Create_WithDepth3AndSingleFileFormat_Succeeds() + { + // Arrange: supply depth at the boundary value alongside single-file format + var args = new[] { "--format", "single-file", "--depth", "3" }; + + // Act + using var context = Context.Create(args); + + // Assert: depth 3 is valid for single-file and both properties must be set + Assert.Equal(3, context.HeadingDepth); + Assert.Equal(OutputFormat.SingleFile, context.Format); + } } diff --git a/test/ApiMark.Tool.Tests/ConsoleCollection.cs b/test/ApiMark.Tool.Tests/ConsoleCollection.cs new file mode 100644 index 0000000..18c89fe --- /dev/null +++ b/test/ApiMark.Tool.Tests/ConsoleCollection.cs @@ -0,0 +1,15 @@ +using Xunit; + +/// +/// xUnit collection definition that serializes tests that mutate +/// global streams (Out and Error). +/// +/// +/// Tests that call or +/// must run serially to avoid cross-test +/// interference with other tests in the same collection. Pair this definition with +/// [Collection("Console")] on each affected test class; xUnit runs all test +/// classes that share a collection definition serially with respect to each other. +/// +[CollectionDefinition("Console")] +public class ConsoleCollection { } diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index 09ba9e4..36bfc6c 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -5,6 +5,7 @@ namespace ApiMark.Tool.Tests; /// Integration tests for . +[Collection("Console")] public class ProgramTests { /// From 5bf7a573a40bdf117beb0c6eff2e4cf9a72884df Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:14:07 -0400 Subject: [PATCH 34/62] revert: restore TFMs in design docs (net8.0/net9.0/net10.0 are framework contracts) TFMs are MSBuild target framework monikers identifying API surface contracts, not version numbers. Restore them in design docs per project decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-tool.md | 12 ++++++------ docs/design/api-mark-tool/program.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/design/api-mark-tool.md b/docs/design/api-mark-tool.md index 8779068..28e26c8 100644 --- a/docs/design/api-mark-tool.md +++ b/docs/design/api-mark-tool.md @@ -7,8 +7,8 @@ ApiMarkTool is organized into two subsystems (`Cli` and `SelfTest`) and one top-level unit (`Program`). It is the .NET executable (`ApiMark.Tool.dll`) distributed as part of -the NuGet package `ApiMark.MSBuild` (under the appropriate framework-specific tool -folders) and optionally as a standalone dotnet tool in the NuGet package +the NuGet package `ApiMark.MSBuild` (under `tools/net8.0/`, `tools/net9.0/`, and +`tools/net10.0/`) and optionally as a standalone dotnet tool in the NuGet package `DemaConsulting.ApiMark.Tool`. - `Cli` subsystem — provides the `Context` unit, which parses command-line arguments into @@ -21,7 +21,7 @@ folders) and optionally as a standalone dotnet tool in the NuGet package `Parse()` to obtain an `IApiEmitter`, then calls `IApiEmitter.Emit()` with an `EmitConfig` built from the parsed context. -ApiMarkTool targets multiple modern .NET framework versions (multi-target). It is packaged as a +ApiMarkTool targets `net8.0;net9.0;net10.0` (multi-target). It is packaged as a dotnet tool (`PackAsTool=true`, `ToolCommandName=apimark`). Because it runs as a standalone process rather than inside the MSBuild host, language generators may depend on any .NET library regardless of `netstandard2.0` support requirements. @@ -106,13 +106,13 @@ N/A - not a safety-classified software item. ## Design Constraints -- Platform: targets multiple modern .NET framework versions; runs on Linux, macOS, and Windows +- Platform: targets `net8.0;net9.0;net10.0`; runs on Linux, macOS, and Windows via `dotnet` or as an installed dotnet tool. - Language extensibility: new language subcommands are added by implementing `IApiGenerator` and adding a dispatch branch; no changes to Core are required. - No direct assembly reflection: all assembly reading is delegated to the appropriate `IApiGenerator` implementation; Program never calls Mono.Cecil or CppAst.Net directly. -- NuGet packaging: bundled inside `ApiMark.MSBuild` under the appropriate framework-specific - tool folders; also published separately as +- NuGet packaging: bundled inside `ApiMark.MSBuild` under `tools/net8.0/`, + `tools/net9.0/`, and `tools/net10.0/`; also published separately as `DemaConsulting.ApiMark.Tool` for direct CLI use. diff --git a/docs/design/api-mark-tool/program.md b/docs/design/api-mark-tool/program.md index b53db5f..e81a6f2 100644 --- a/docs/design/api-mark-tool/program.md +++ b/docs/design/api-mark-tool/program.md @@ -19,7 +19,7 @@ construction, and help/banner printing. **Program.Version** (public static `string` property): Returns the informational version string read from `AssemblyInformationalVersionAttribute` via reflection, with -fallback to `AssemblyVersion`, then a safe placeholder string. Used by `PrintBanner` +fallback to `AssemblyVersion`, then `"0.0.0"`. Used by `PrintBanner` and `Run` when responding to `--version`. **Context** (`Cli/Context.cs`): See _Cli Subsystem Design_ (`cli.md`) and From 92b635c002ae0d8b3989c177c549850811cdfa84 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:23:38 -0400 Subject: [PATCH 35/62] fix: restore netstandard2.0 and net8.0 TFMs in MSBuild design doc TFMs are permitted in design docs per updated design-documentation standard. Restore netstandard2.0, net8.0, and tools/net8.0/ path to their original values in docs/design/api-mark-msbuild.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/standards/design-documentation.md | 6 +- docs/design/api-mark-msbuild.md | 20 ++-- docs/reqstream/api-mark-tool/cli/context.yaml | 10 ++ docs/reqstream/api-mark-tool/program.yaml | 4 +- .../verification/api-mark-tool/cli/context.md | 9 ++ docs/verification/api-mark-tool/program.md | 11 ++- src/ApiMark.Tool/Cli/Context.cs | 8 +- test/ApiMark.Tool.Tests/Cli/ContextTests.cs | 30 ++++++ test/ApiMark.Tool.Tests/ProgramTests.cs | 91 ++++++++++++++++--- 9 files changed, 160 insertions(+), 29 deletions(-) diff --git a/.github/standards/design-documentation.md b/.github/standards/design-documentation.md index e5b7bf9..359a743 100644 --- a/.github/standards/design-documentation.md +++ b/.github/standards/design-documentation.md @@ -30,7 +30,6 @@ docs/design/ All sections in every file are mandatory; write "N/A - {justification}" rather than removing any. Determine subsystem vs. unit classification from `docs/design/introduction.md` — folder depth does not determine classification. -Do not record version numbers anywhere in design documentation — version information is managed in SBOMs. # introduction.md (MANDATORY) @@ -98,6 +97,11 @@ For each Shared Package, create `docs/design/shared/{package-name}.md` (`##` hea - Use Mermaid diagrams to supplement (not replace) text - Use verbal cross-references ("see _Parser Design_") - not markdown hyperlinks (break in PDF) - Provide sufficient detail for formal code review +- Do not record version numbers in design documentation — they go stale with dependency updates and + are managed in SBOMs. Version numbers are pinned release versions (e.g., `1.2.3`, `v2.0.1`). + The following are **not** version numbers and are permitted: + - .NET TFMs: `netstandard2.0`, `net10.0` (platform targets, not release versions) + - Placeholders: `0.0.0` (signals "not yet assigned") # Quality Checks diff --git a/docs/design/api-mark-msbuild.md b/docs/design/api-mark-msbuild.md index ad0dfef..1daff98 100644 --- a/docs/design/api-mark-msbuild.md +++ b/docs/design/api-mark-msbuild.md @@ -13,14 +13,14 @@ Visual Studio C++ builds (Windows). Rather than calling language generators in-process, ApiMarkTask spawns the `ApiMark.Tool` .NET executable as a child process, passing all configuration as command-line arguments. This out-of-process design allows the language generators -to target a modern .NET framework freely — using libraries that do not support the -MSBuild-compatible .NET Standard target such as newer versions of CppAst.Net — while -the MSBuild task itself targets a .NET Standard-compatible target for full compatibility -with both .NET Framework MSBuild (Visual Studio) and the .NET SDK MSBuild (`dotnet build`). +to target a modern .NET framework freely — using libraries that do not support +`netstandard2.0` such as newer versions of CppAst.Net — while the MSBuild task itself +targets `netstandard2.0` for full compatibility with both .NET Framework MSBuild (Visual +Studio) and the .NET SDK MSBuild (`dotnet build`). The NuGet package `ApiMark.MSBuild` bundles both the task assembly and the -pre-compiled `ApiMark.Tool` DLL and its dependencies under a `tools/` subfolder. -The `.targets` file wires `ApiMarkTask` into `AfterTargets="Build"` and sets the +pre-compiled `ApiMark.Tool` DLL and its dependencies under `tools/net8.0/`. The +`.targets` file wires `ApiMarkTask` into `AfterTargets="Build"` and sets the `ToolDllPath` property to the bundled tool location. When `ApiMarkPackDocs` is set to `true`, the `.targets` file also hooks the @@ -34,7 +34,7 @@ project explicitly opts in. **MSBuild task (provided)**: ApiMarkTask is consumed by MSBuild as an in-process task. -- *Type*: MSBuild task (.NET Standard-compatible target, in-process in the MSBuild host). +- *Type*: MSBuild task (`netstandard2.0`, in-process in the MSBuild host). - *Role*: Provider — `.csproj` and `.vcxproj` files reference the task via the NuGet package; MSBuild invokes the task at build time. - *Contract*: MSBuild properties `ApiMarkLanguage`, `ApiMarkOutputDir`, @@ -130,10 +130,10 @@ bundled in the NuGet package. ## Design Constraints -- Task platform: targets a .NET Standard-compatible target so the same assembly runs in .NET +- Task platform: targets `netstandard2.0` so the same assembly runs in .NET Framework MSBuild (Visual Studio) and .NET SDK MSBuild (`dotnet build`). -- Tool platform: `ApiMark.Tool.dll` targets a modern .NET framework; language generators are free - to use any library regardless of .NET Standard support. +- Tool platform: `ApiMark.Tool.dll` targets `net8.0`; language generators are free + to use any library regardless of `netstandard2.0` support. - Cross-platform `dotnet` resolution: `DOTNET_HOST_PATH` is set by the SDK on all platforms; the task must fall back to searching `PATH` for environments where it is not set (e.g., Visual Studio on .NET Framework MSBuild). diff --git a/docs/reqstream/api-mark-tool/cli/context.yaml b/docs/reqstream/api-mark-tool/cli/context.yaml index f04cf36..373b5a4 100644 --- a/docs/reqstream/api-mark-tool/cli/context.yaml +++ b/docs/reqstream/api-mark-tool/cli/context.yaml @@ -196,3 +196,13 @@ sections: tests: - Context_Create_WithDepthAbove3AndSingleFileFormat_ThrowsArgumentException - Context_Create_WithDepth3AndSingleFileFormat_Succeeds + - id: ApiMarkTool-Cli-Context-RejectFlagAsOptionValue + title: Context shall throw ArgumentException when a flag token (starting + with '-') is supplied as the value of a string-valued option. + justification: | + Accepting a flag token as a value silently consumes it as data instead of + raising a parse error, causing confusing behavior where the intended flag is + never applied and the option receives a nonsensical value. + tests: + - Context_Create_WithFlagValueForOutput_ThrowsArgumentException + - Context_Create_WithFlagValueForLog_ThrowsArgumentException diff --git a/docs/reqstream/api-mark-tool/program.yaml b/docs/reqstream/api-mark-tool/program.yaml index 0430daf..7249976 100644 --- a/docs/reqstream/api-mark-tool/program.yaml +++ b/docs/reqstream/api-mark-tool/program.yaml @@ -79,7 +79,9 @@ sections: justification: | Self-validation confirms tool integrity in deployment environments without requiring external test infrastructure or the full source tree. - tests: [Program_Main_WithValidateFlag_ExitsZero] + tests: + - Program_Main_WithValidateFlag_ExitsZero + - Program_Main_WithValidateAndResultsFile_WritesResultsFile - id: ApiMarkTool-Program-SupportVersionOption title: Program shall support a global --version option. justification: | diff --git a/docs/verification/api-mark-tool/cli/context.md b/docs/verification/api-mark-tool/cli/context.md index 32f96fc..0eb44c6 100644 --- a/docs/verification/api-mark-tool/cli/context.md +++ b/docs/verification/api-mark-tool/cli/context.md @@ -26,6 +26,7 @@ up after itself. No other external files, services, or configuration are require - When `--format single-file` is in effect, `--depth` values above 3 throw `ArgumentException` regardless of argument order. - `--depth 3` with `--format single-file` is accepted (boundary value). - `--results`/`--result` sets `ResultsFile` to the supplied path. +- Flag tokens (starting with `-`) supplied as values for string-valued options are rejected with `ArgumentException`. - `--includes` accepts one directory path per flag; repeated flags accumulate paths into `Includes`. - `--api-headers` patterns are accumulated in order; `!`-prefixed exclusion patterns are forwarded verbatim. - `--source` patterns are accumulated in order; `!`-prefixed exclusion patterns are forwarded verbatim. @@ -158,3 +159,11 @@ corresponding properties set simultaneously. **`Context_Create_WithDepth3AndSingleFileFormat_Succeeds`**: `--format single-file --depth 3` parses successfully with `HeadingDepth = 3` and `Format = OutputFormat.SingleFile` (boundary value is accepted). + +**`Context_Create_WithFlagValueForOutput_ThrowsArgumentException`**: `["dotnet", "--output", "--silent"]` +— a flag token supplied as the `--output` value — throws `ArgumentException`, confirming +that the parser rejects flag tokens as string-valued option values. + +**`Context_Create_WithFlagValueForLog_ThrowsArgumentException`**: `["--log", "--silent"]` +— a flag token supplied as the `--log` value — throws `ArgumentException`, confirming +that the parser rejects flag tokens as string-valued option values. diff --git a/docs/verification/api-mark-tool/program.md b/docs/verification/api-mark-tool/program.md index 4aeffa0..3687b80 100644 --- a/docs/verification/api-mark-tool/program.md +++ b/docs/verification/api-mark-tool/program.md @@ -25,6 +25,7 @@ output directory. No external service, privileged configuration, or network acce - `--help` before and after a subcommand both display usage information and return exit code zero. - `--silent` suppresses console output; `--log ` captures output to a file. - `--validate` runs self-validation tests and returns exit code zero when all pass. +- `--validate --results ` additionally writes the results to the specified file path. - The `vhdl` subcommand rejects invocations without at least one non-exclusion `--source` pattern. ### Test Scenarios @@ -53,14 +54,18 @@ including "Usage:" and "Options:" sections and returns exit code zero. This scen accepts `--help` after the language token and still dispatches to help display. This scenario is tested by `Program_Main_WithHelpAfterSubcommand_PrintsHelpAndExitsZero`. -**Silent and log options produce log file**: Verifies that `--silent` suppresses console output and -`--log ` creates a non-empty log file containing the captured output. This scenario is tested -by `Program_Main_WithSilentAndLog_DotNetCommand_ExitsZero`. +**Silent and log options produce log file**: Verifies that `--silent` suppresses all console output +(stdout and stderr are both empty) and `--log ` creates a non-empty log file containing the +captured output. This scenario is tested by `Program_Main_WithSilentAndLog_DotNetCommand_ExitsZero`. **Validate flag runs self-validation and exits zero**: Verifies that `--validate` executes internal self-validation tests and returns exit code zero when all tests pass. This scenario is tested by `Program_Main_WithValidateFlag_ExitsZero`. +**Validate flag with results file writes results**: Verifies that `--validate --results ` +executes internal self-validation tests, writes a results file to the specified path, and returns +exit code zero. This scenario is tested by `Program_Main_WithValidateAndResultsFile_WritesResultsFile`. + **Version flag prints version and exits zero**: Verifies that `--version` prints a non-empty version string to stdout and returns exit code zero without printing the application banner. This scenario is tested by `Program_Main_WithVersionFlag_PrintsVersionAndExitsZero`. diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index aed23f7..1b12fec 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -593,7 +593,13 @@ private static string GetRequiredStringArgument(string arg, string[] args, int i throw new ArgumentException($"{arg} requires {description}", nameof(args)); } - return args[index]; + var value = args[index]; + if (value.StartsWith('-')) + { + throw new ArgumentException($"{arg} requires {description} but got another flag '{value}'", nameof(args)); + } + + return value; } /// diff --git a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs index 806bddf..5ec0459 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -755,4 +755,34 @@ public void Context_Create_WithDepth3AndSingleFileFormat_Succeeds() Assert.Equal(3, context.HeadingDepth); Assert.Equal(OutputFormat.SingleFile, context.Format); } + + /// + /// Validates that supplying a flag token (starting with '-') as the value + /// of --output throws . + /// + [Fact] + public void Context_Create_WithFlagValueForOutput_ThrowsArgumentException() + { + // Arrange: supply a flag token as the --output value — the parser must reject it + // rather than silently consuming the flag as a path string + var args = new[] { "dotnet", "--output", "--silent" }; + + // Act / Assert: a flag token supplied as an option value must throw ArgumentException + Assert.Throws(() => Context.Create(args)); + } + + /// + /// Validates that supplying a flag token (starting with '-') as the value + /// of --log throws . + /// + [Fact] + public void Context_Create_WithFlagValueForLog_ThrowsArgumentException() + { + // Arrange: supply a flag token as the --log value — the parser must reject it + // rather than silently consuming the flag as a filename string + var args = new[] { "--log", "--silent" }; + + // Act / Assert: a flag token supplied as an option value must throw ArgumentException + Assert.Throws(() => Context.Create(args)); + } } diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index 36bfc6c..569905d 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -49,7 +49,7 @@ public void Program_Main_DotNetCommand_GeneratesExpectedOutput() /// /// Validates that supplying an unrecognized --visibility value exits - /// with a non-zero code. + /// with a non-zero code and writes an error message containing the invalid value. /// [Fact] public void Program_Main_WithInvalidVisibility_ReturnsNonZeroExitCode() @@ -57,17 +57,31 @@ public void Program_Main_WithInvalidVisibility_ReturnsNonZeroExitCode() // Arrange: supply a visibility value that does not map to any ApiVisibility member var assemblyPath = typeof(SampleClass).Assembly.Location; var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); - // Act - var exitCode = Program.Main([ - "dotnet", - "--assembly", assemblyPath, - "--output", outputDir, - "--visibility", "InvalidValue", - ]); + try + { + Console.SetError(errorWriter); - // Assert: invalid visibility must produce a non-zero exit code - Assert.NotEqual(0, exitCode); + // Act + var exitCode = Program.Main([ + "dotnet", + "--assembly", assemblyPath, + "--output", outputDir, + "--visibility", "InvalidValue", + ]); + + // Assert: invalid visibility must produce a non-zero exit code and a diagnostic + // that names the offending value so the user can identify and correct the error + Assert.NotEqual(0, exitCode); + Assert.Contains("InvalidValue", errorWriter.ToString(), StringComparison.Ordinal); + } + finally + { + // Restore the original error stream regardless of outcome + Console.SetError(originalError); + } } /// @@ -94,8 +108,10 @@ public void Program_Main_WithMissingAssembly_PrintsErrorAndFails() ]); // Assert: missing assembly must produce a non-zero exit code and a descriptive error message + // that names the missing path so the user can identify the problem immediately Assert.NotEqual(0, exitCode); Assert.False(string.IsNullOrWhiteSpace(errorWriter.ToString()), "Expected error message on stderr"); + Assert.Contains("path/does/not/exist.dll", errorWriter.ToString(), StringComparison.Ordinal); } finally { @@ -218,8 +234,9 @@ public void Program_Main_WithCppSubcommand_MissingIncludes_ReturnsNonZeroExitCod /// /// Validates that --silent and --log are accepted alongside the - /// dotnet subcommand, that the tool exits with code 0, and that the log - /// file is created and non-empty. + /// dotnet subcommand, that the tool exits with code 0, that the log + /// file is created and non-empty, and that no output is written to stdout or + /// stderr when --silent is active. /// [Fact] public void Program_Main_WithSilentAndLog_DotNetCommand_ExitsZero() @@ -229,9 +246,16 @@ public void Program_Main_WithSilentAndLog_DotNetCommand_ExitsZero() var xmlDocPath = Path.ChangeExtension(assemblyPath, ".xml"); var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); var logFile = Path.Join(Path.GetTempPath(), Path.GetRandomFileName() + ".log"); + var originalOut = Console.Out; + var originalError = Console.Error; + using var outWriter = new StringWriter(); + using var errWriter = new StringWriter(); try { + Console.SetOut(outWriter); + Console.SetError(errWriter); + // Act var exitCode = Program.Main([ "--silent", @@ -242,13 +266,19 @@ public void Program_Main_WithSilentAndLog_DotNetCommand_ExitsZero() "--output", outputDir, ]); - // Assert: exits zero; log file exists and is non-empty + // Assert: exits zero; console is fully silent; log file exists and is non-empty Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, outWriter.ToString()); + Assert.Equal(string.Empty, errWriter.ToString()); Assert.True(File.Exists(logFile), "Expected log file to be created"); Assert.True(new FileInfo(logFile).Length > 0, "Expected log file to be non-empty"); } finally { + // Restore console streams and clean up temporary paths regardless of outcome + Console.SetOut(originalOut); + Console.SetError(originalError); + if (Directory.Exists(outputDir)) { Directory.Delete(outputDir, recursive: true); @@ -404,4 +434,39 @@ public void Program_Main_WithVhdlSubcommand_MissingSourceFiles_ReturnsNonZeroExi Console.SetError(originalError); } } + + /// + /// Validates that --validate --results <file> writes a results file to + /// the specified path and exits with code 0. + /// + [Fact] + public void Program_Main_WithValidateAndResultsFile_WritesResultsFile() + { + // Arrange: create a temporary results file path that does not yet exist + var resultsFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".trx"); + var originalOut = Console.Out; + using var outWriter = new StringWriter(); + + try + { + Console.SetOut(outWriter); + + // Act + var exitCode = Program.Main(["--validate", "--results", resultsFile]); + + // Assert: self-validation succeeds and the results file is written to the requested path + Assert.Equal(0, exitCode); + Assert.True(File.Exists(resultsFile), "Expected results file to be created"); + } + finally + { + // Restore console and remove the temporary results file regardless of outcome + Console.SetOut(originalOut); + + if (File.Exists(resultsFile)) + { + File.Delete(resultsFile); + } + } + } } From 4fc98f25bed23a5856210e89b80308fdaa1c38b3 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:29:43 -0400 Subject: [PATCH 36/62] chore: update design-documentation standard and restore c++17 TFM - Broaden 'not version numbers' list to language/platform standards and protocol standards - Explicitly permit: netstandard2.0, net10.0, C++20, C# 12, TLS 1.3, HTTP/2 - Restore c++17 in MSBuild design doc (C++ language standard identifier, now permitted) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/standards/design-documentation.md | 3 ++- docs/design/api-mark-msbuild.md | 4 ++-- test/ApiMark.Tool.Tests/ProgramTests.cs | 15 ++++++++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/standards/design-documentation.md b/.github/standards/design-documentation.md index 359a743..4d413c6 100644 --- a/.github/standards/design-documentation.md +++ b/.github/standards/design-documentation.md @@ -100,7 +100,8 @@ For each Shared Package, create `docs/design/shared/{package-name}.md` (`##` hea - Do not record version numbers in design documentation — they go stale with dependency updates and are managed in SBOMs. Version numbers are pinned release versions (e.g., `1.2.3`, `v2.0.1`). The following are **not** version numbers and are permitted: - - .NET TFMs: `netstandard2.0`, `net10.0` (platform targets, not release versions) + - Language/platform standards: `netstandard2.0`, `net10.0`, `C++20`, `C# 12` (stable standard identifiers) + - Protocol standards: `TLS 1.3`, `HTTP/2` (stable specifications) - Placeholders: `0.0.0` (signals "not yet assigned") # Quality Checks diff --git a/docs/design/api-mark-msbuild.md b/docs/design/api-mark-msbuild.md index 1daff98..59226a7 100644 --- a/docs/design/api-mark-msbuild.md +++ b/docs/design/api-mark-msbuild.md @@ -54,8 +54,8 @@ task. (semicolon-delimited ordered glob/exclusion patterns; optional), `ApiMarkDefines` (semicolon-delimited preprocessor defines; semicolons converted to commas for the tool argument), - `ApiMarkCppStandard` (a C++ language standard identifier; optional, defaulted to a - recent C++ standard by the `.targets` file when not set by the project), + `ApiMarkCppStandard` (e.g. `c++17`; optional, defaulted to `c++17` by the `.targets` file + when not set by the project), `ApiMarkClangPath` (optional explicit clang executable path). Optional input item group `ApiMarkOutput` (exposed to MSBuild projects via the task property `ApiMarkOutputs`); each item carries `OutputDir`, `Format`, and `Visibility` metadata that diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index 569905d..295aa7e 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -54,8 +54,10 @@ public void Program_Main_DotNetCommand_GeneratesExpectedOutput() [Fact] public void Program_Main_WithInvalidVisibility_ReturnsNonZeroExitCode() { - // Arrange: supply a visibility value that does not map to any ApiVisibility member + // Arrange: supply a visibility value that does not map to any ApiVisibility member; + // --xml-doc must also be present so the tool reaches the visibility validation step var assemblyPath = typeof(SampleClass).Assembly.Location; + var xmlDocPath = Path.ChangeExtension(assemblyPath, ".xml"); var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); var originalError = Console.Error; using var errorWriter = new StringWriter(); @@ -68,6 +70,7 @@ public void Program_Main_WithInvalidVisibility_ReturnsNonZeroExitCode() var exitCode = Program.Main([ "dotnet", "--assembly", assemblyPath, + "--xml-doc", xmlDocPath, "--output", outputDir, "--visibility", "InvalidValue", ]); @@ -86,12 +89,13 @@ public void Program_Main_WithInvalidVisibility_ReturnsNonZeroExitCode() /// /// Validates that supplying a path to a non-existent assembly exits with - /// a non-zero code and writes a descriptive error message. + /// a non-zero code and writes a descriptive error message containing the path. /// [Fact] public void Program_Main_WithMissingAssembly_PrintsErrorAndFails() { - // Arrange: use a path that is guaranteed not to exist + // Arrange: use paths that are guaranteed not to exist; --xml-doc must also be present + // so that validation reaches the generator stage where the missing assembly is detected var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); var originalError = Console.Error; using var errorWriter = new StringWriter(); @@ -104,14 +108,15 @@ public void Program_Main_WithMissingAssembly_PrintsErrorAndFails() var exitCode = Program.Main([ "dotnet", "--assembly", "path/does/not/exist.dll", + "--xml-doc", "path/does/not/exist.xml", "--output", outputDir, ]); // Assert: missing assembly must produce a non-zero exit code and a descriptive error message - // that names the missing path so the user can identify the problem immediately + // that identifies the assembly-not-found condition so the user can diagnose the problem Assert.NotEqual(0, exitCode); Assert.False(string.IsNullOrWhiteSpace(errorWriter.ToString()), "Expected error message on stderr"); - Assert.Contains("path/does/not/exist.dll", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains("Assembly file not found", errorWriter.ToString(), StringComparison.Ordinal); } finally { From 4ccece6906d19d75d9e247265f5461894f816326 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:30:01 -0400 Subject: [PATCH 37/62] fix: restore vhdl2008 grammar name in design introduction vhdl2008 is a language standard identifier, permitted per updated design-documentation standard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/introduction.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 8d94461..cbbba69 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -12,7 +12,7 @@ systems: ApiMark.Core (shared contracts and file-path helpers), ApiMark.DotNet .NET executable invoked by ApiMarkTask and directly by users or CI pipelines). Several OTS items provide library support: Mono.Cecil for the DotNet system, clang (via `clang -ast-dump=json`) for the Cpp system, DemaConsulting.TestResults for the SelfTest -subsystem, Antlr4.Runtime.Standard with an ANTLR4 VHDL grammar for the Vhdl system, and +subsystem, Antlr4.Runtime.Standard with an ANTLR4 vhdl2008 grammar for the Vhdl system, and Microsoft.Extensions.FileSystemGlobbing for glob-based file discovery in ApiMarkCore. An archived OTS item, cpp-ast-net, is retained for historical reference. @@ -33,7 +33,7 @@ OTS items: - **Mono.Cecil**: integration and usage design. - **clang**: integration and usage design (via `clang -ast-dump=json`). - **DemaConsulting.TestResults**: integration and usage design. -- **Antlr4.Runtime.Standard / ANTLR4 VHDL grammar**: integration and usage design. +- **Antlr4.Runtime.Standard / ANTLR4 vhdl2008 grammar**: integration and usage design. - **Microsoft.Extensions.FileSystemGlobbing**: integration and usage design. - **cpp-ast-net**: integration and usage design (archived; retained for historical reference). @@ -96,7 +96,7 @@ OTS Dependencies: ├── Mono.Cecil (OTS) ├── DemaConsulting.TestResults (OTS) ├── clang -ast-dump=json (OTS) -├── Antlr4.Runtime.Standard / ANTLR4 VHDL grammar (OTS) +├── Antlr4.Runtime.Standard / ANTLR4 vhdl2008 grammar (OTS) ├── Microsoft.Extensions.FileSystemGlobbing (OTS) └── cpp-ast-net (OTS) [archived] ``` From 0e13ae247e2aa0516d5a2e8a032f61d8425e1231 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:39:23 -0400 Subject: [PATCH 38/62] chore: add xUnit v3 to permitted identifiers in design-documentation standard xUnit v3 is an API/specification variant name, not a pinned release version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/standards/design-documentation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/standards/design-documentation.md b/.github/standards/design-documentation.md index 4d413c6..a8cec68 100644 --- a/.github/standards/design-documentation.md +++ b/.github/standards/design-documentation.md @@ -101,6 +101,7 @@ For each Shared Package, create `docs/design/shared/{package-name}.md` (`##` hea are managed in SBOMs. Version numbers are pinned release versions (e.g., `1.2.3`, `v2.0.1`). The following are **not** version numbers and are permitted: - Language/platform standards: `netstandard2.0`, `net10.0`, `C++20`, `C# 12` (stable standard identifiers) + - Test framework variants: `xUnit v3` (API/specification variant name, not a release version) - Protocol standards: `TLS 1.3`, `HTTP/2` (stable specifications) - Placeholders: `0.0.0` (signals "not yet assigned") From 28dff53cbc58ed9c213f65e16ef79348cb35ef3d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:39:32 -0400 Subject: [PATCH 39/62] Revert "chore: add xUnit v3 to permitted identifiers in design-documentation standard" This reverts commit 0e13ae247e2aa0516d5a2e8a032f61d8425e1231. --- .github/standards/design-documentation.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/standards/design-documentation.md b/.github/standards/design-documentation.md index a8cec68..4d413c6 100644 --- a/.github/standards/design-documentation.md +++ b/.github/standards/design-documentation.md @@ -101,7 +101,6 @@ For each Shared Package, create `docs/design/shared/{package-name}.md` (`##` hea are managed in SBOMs. Version numbers are pinned release versions (e.g., `1.2.3`, `v2.0.1`). The following are **not** version numbers and are permitted: - Language/platform standards: `netstandard2.0`, `net10.0`, `C++20`, `C# 12` (stable standard identifiers) - - Test framework variants: `xUnit v3` (API/specification variant name, not a release version) - Protocol standards: `TLS 1.3`, `HTTP/2` (stable specifications) - Placeholders: `0.0.0` (signals "not yet assigned") From 80b1918660e3a8cb254a14ef9dd6f7a3ad3ce35d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:43:52 -0400 Subject: [PATCH 40/62] fix(requirements): tighten OTS requirements and verification docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DemaConsultingTestResults-Results-RecordTestOutcomes: rewrite title as a single observable criterion (container/record structure) instead of enumerating individual fields - dema-consulting-test-results.md: rewrite scenario 'Version and help self-test names appear in log output' to match what Validation_Run_WritesVersionAndHelpTestResults actually asserts (log contains ApiMark_VersionDisplay and ApiMark_HelpDisplay names) rather than incorrectly claiming it checks TestOutcome.Passed values - file-system-globbing.yaml: remove FileSystemGlobbing-Matcher-ExcludePatterns and FileSystemGlobbing-Matcher-NonExistentRoot — both behaviors belong to GlobFileCollector (already covered by ApiMarkCore-GlobFileCollector- SupportExclusionPatterns and ApiMarkCore-GlobFileCollector-SkipNonExistentRoots) - file-system-globbing.md: remove verification scenarios for exclusion patterns and non-existent roots; update approach paragraph to scope Matcher verification to include-pattern matching only --- .../ots/dema-consulting-test-results.yaml | 5 +---- docs/reqstream/ots/file-system-globbing.yaml | 17 ----------------- .../ots/dema-consulting-test-results.md | 8 ++++---- docs/verification/ots/file-system-globbing.md | 14 +------------- 4 files changed, 6 insertions(+), 38 deletions(-) diff --git a/docs/reqstream/ots/dema-consulting-test-results.yaml b/docs/reqstream/ots/dema-consulting-test-results.yaml index 5388dde..1057ddc 100644 --- a/docs/reqstream/ots/dema-consulting-test-results.yaml +++ b/docs/reqstream/ots/dema-consulting-test-results.yaml @@ -8,10 +8,7 @@ sections: - id: DemaConsultingTestResults-Results-RecordTestOutcomes title: >- DemaConsulting.TestResults shall provide a TestResults container and - TestResult record - that captures the name, outcome, error message, and duration for each - self-validation - test case. + TestResult record for aggregating individual test outcomes. justification: | The SelfTest subsystem must collect pass/fail evidence for each self-validation scenario and report totals to the user. DemaConsulting.TestResults provides the diff --git a/docs/reqstream/ots/file-system-globbing.yaml b/docs/reqstream/ots/file-system-globbing.yaml index 42234ff..a9e8ab1 100644 --- a/docs/reqstream/ots/file-system-globbing.yaml +++ b/docs/reqstream/ots/file-system-globbing.yaml @@ -15,20 +15,3 @@ sections: tests: - GlobFileCollector_Collect_RelativeVhdPattern_FindsVhdFiles - GlobFileCollector_Collect_AbsolutePattern_FindsFiles - - id: FileSystemGlobbing-Matcher-ExcludePatterns - title: Microsoft.Extensions.FileSystemGlobbing shall support exclusion - patterns that remove previously matched files from the result set. - justification: | - GlobFileCollector uses exclusion patterns (! prefix) to let callers - narrow the file set after broad inclusions. The Matcher must honor these - correctly so that excluded files never appear in documentation output. - tests: [GlobFileCollector_Collect_ExclusionPattern_RemovesMatchedFiles] - - id: FileSystemGlobbing-Matcher-NonExistentRoot - title: Microsoft.Extensions.FileSystemGlobbing shall return an empty result - without throwing when the pattern root directory does not exist. - justification: | - GlobFileCollector silently skips missing roots. This behavior relies on - Matcher not throwing for absent directories, avoiding spurious build - failures when optional source roots are absent in CI. - tests: - - GlobFileCollector_Collect_NonExistentRoot_ReturnsEmptyWithoutThrowing diff --git a/docs/verification/ots/dema-consulting-test-results.md b/docs/verification/ots/dema-consulting-test-results.md index d4ee640..98682b6 100644 --- a/docs/verification/ots/dema-consulting-test-results.md +++ b/docs/verification/ots/dema-consulting-test-results.md @@ -12,10 +12,10 @@ the expected file content and outcome counts. ### Test Scenarios -**Version and help self-tests record Passed outcomes**: Verifies that when the `--version` -and `--help` child invocations succeed, `Validation.Run` creates `TestResult` entries with -`Outcome = TestOutcome.Passed` and adds them to the `TestResults` collection. This scenario -is tested by `Validation_Run_WritesVersionAndHelpTestResults`. +**Version and help self-test names appear in log output**: Verifies that when `Validation.Run` +executes, the log output contains the names of both self-test cases: `ApiMark_VersionDisplay` +and `ApiMark_HelpDisplay`. This scenario is tested by +`Validation_Run_WritesVersionAndHelpTestResults`. **Results collection serializes to TRX when requested**: Verifies that when `context.ResultsFile` has a `.trx` extension, `WriteResultsFile` calls `TrxSerializer.Serialize` and writes the result diff --git a/docs/verification/ots/file-system-globbing.md b/docs/verification/ots/file-system-globbing.md index f65caeb..2e105c2 100644 --- a/docs/verification/ots/file-system-globbing.md +++ b/docs/verification/ots/file-system-globbing.md @@ -6,7 +6,7 @@ `test/ApiMark.Core.Tests/GlobFileCollectorTests.cs` that exercise `GlobFileCollector`, the sole consumer of the library. The tests operate against temporary real directories on disk, confirming that the `Matcher` API behaves as expected for the subset of -features ApiMark uses: include patterns, exclude patterns, and non-existent roots. +features ApiMark uses: include patterns rooted at a given directory. ### Test Scenarios @@ -19,15 +19,3 @@ directory. This scenario is tested by root is derived from the longest non-glob prefix finds the expected files without requiring a separate working directory. This scenario is tested by `GlobFileCollector_Collect_AbsolutePattern_FindsFiles`. - -**Exclusion pattern removes matched files**: Verifies that a `!`-prefixed exclusion -pattern correctly removes files that would otherwise be matched by a preceding include -pattern. `GlobFileCollector` runs `GetResultsInFullPath` for the exclusion pattern and -calls `collected.Remove()` for each result, ensuring that excluded files never appear -in the output. This scenario is tested by -`GlobFileCollector_Collect_ExclusionPattern_RemovesMatchedFiles`. - -**Non-existent root returns empty without throwing**: Verifies that a pattern whose -root directory does not exist produces an empty result rather than throwing an -exception. This scenario is tested by -`GlobFileCollector_Collect_NonExistentRoot_ReturnsEmptyWithoutThrowing`. From 75e1bf2d05638bcd685fe6c363345939c4a3f873 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:49:30 -0400 Subject: [PATCH 41/62] fix: OTS R2 - narrow TestResults verification approach to match actual test evidence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ots/dema-consulting-test-results.md | 8 +- review-template-downloaded.md | 266 ++++++++++++++++++ 2 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 review-template-downloaded.md diff --git a/docs/verification/ots/dema-consulting-test-results.md b/docs/verification/ots/dema-consulting-test-results.md index 98682b6..ec11852 100644 --- a/docs/verification/ots/dema-consulting-test-results.md +++ b/docs/verification/ots/dema-consulting-test-results.md @@ -5,10 +5,10 @@ DemaConsulting.TestResults is verified in ApiMark through the existing self-validation tests in `test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs` that exercise the pass/fail recording and results-file writing paths of `Validation.cs`. The verification focus is the subset of capabilities -needed by the product: creating a `TestResults` collection, recording `Passed` and `Failed` -outcomes on individual `TestResult` objects, and serializing the collection to TRX and JUnit XML -via `TrxSerializer` and `JUnitSerializer`. Evidence is collected from automated tests that confirm -the expected file content and outcome counts. +needed by the product: creating a `TestResults` collection, recording test outcomes on individual +`TestResult` objects, and serializing the collection to TRX and JUnit XML via `TrxSerializer` and +`JUnitSerializer`. Evidence is collected from automated tests that confirm test names appear in log +output and that results files are created at the specified paths. ### Test Scenarios diff --git a/review-template-downloaded.md b/review-template-downloaded.md new file mode 100644 index 0000000..744a522 --- /dev/null +++ b/review-template-downloaded.md @@ -0,0 +1,266 @@ +# _[Review Title]_ + +## 1. Introduction + +### 1.1 Purpose + +This document records the formal review of a set of project files. + +### 1.2 Scope + +_[Describe the scope of this review, including which project, release, or change is being reviewed.]_ + +### 1.3 Outcomes + +Each check must be recorded with one of the following outcomes: + +| Outcome | Meaning | +| :------ | :------ | +| Pass | The check was performed and the criterion is satisfied | +| Fail | The check was performed and the criterion is not satisfied | +| N/A | The check does not apply; justification is required | + +### 1.4 Review Details + +| Field | Value | +| :---- | :---- | +| Project | _[Project name]_ | +| Review ID | _[Review identifier]_ | +| Review Title | _[Review title]_ | +| Fingerprint | _[Fingerprint of the complete file set]_ | +| Review Date | _[YYYY-MM-DD]_ | + +### 1.5 Reviewers + +| Name | Role | Organization | Signature | Date | +| :--- | :--- | :----------- | :-------- | :--- | +| _[Reviewer name]_ | _[Role]_ | _[Organization]_ | _[Signature]_ | _[YYYY-MM-DD]_ | +| _[Reviewer name]_ | _[Role]_ | _[Organization]_ | _[Signature]_ | _[YYYY-MM-DD]_ | + +### 1.6 Files Under Review + +| File | +| :--- | +| _[filename]_ | + +--- + +## 2. Review Checklist + +### 2.1 Requirements Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no requirements files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| REQ-01 | All requirements have a unique identifier | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-02 | All requirements are unambiguous (only one valid interpretation) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-03 | All requirements are testable (compliance can be demonstrated by a test) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-04 | All requirements are consistent (no requirement contradicts another) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-05 | All requirements are complete (no TBDs, undefined terms, or missing information) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-06 | All requirements are verifiable (can be objectively confirmed as met or not met) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-07 | No compound requirements are present (each requirement expresses a single testable criterion) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQ-08 | No requirements are missing (all expected behaviors and constraints are specified) | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.2 Design Documentation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no design documentation files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| DES-01 | Design documentation clearly describes the purpose of the component or feature | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DES-02 | Design documentation covers the necessary implementation details | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DES-03 | Design documentation describes how the code is interfaced (APIs, inputs, outputs) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DES-04 | Design documentation describes the expected normal operation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DES-05 | Design documentation describes the expected error handling | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.3 Technical Documentation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no general technical documentation files (e.g., user guides, +API references, README files, release notes). If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| DOC-01 | Documentation is free of technical inaccuracies | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DOC-02 | Documentation is consistent with the current implementation and requirements | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DOC-03 | All referenced external documents and dependencies are correctly identified | Pass / Fail / N/A | _Required if Fail or N/A_ | +| DOC-04 | Documentation is free of spelling and grammar errors | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.4 Code Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| CODE-01 | Code conforms to the project coding standards and style guide | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-02 | No obvious resource leaks are present (file handles, connections, memory) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-03 | No hardcoded values are present that should be configurable | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-04 | Each unit or function has a single, well-defined responsibility | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-05 | Code is written at the appropriate level of abstraction | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODE-06 | Code has an appropriate amount of extensibility for its context | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.5 Logic Error Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| LOGIC-01 | Code does only what is intended (no unintended side effects or behaviors) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| LOGIC-02 | All significant inputs and boundary conditions are handled correctly | Pass / Fail / N/A | _Required if Fail or N/A_ | +| LOGIC-03 | Concurrency and threading concerns are identified and addressed | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.6 Error Handling & Logging Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| ERR-01 | Error handling follows the approach described in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| ERR-02 | The logging volume and level of detail are appropriate | Pass / Fail / N/A | _Required if Fail or N/A_ | +| ERR-03 | Error messages are user-friendly and actionable | Pass / Fail / N/A | _Required if Fail or N/A_ | +| ERR-04 | Error messages and log entries do not leak sensitive data | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.7 Usability / Accessibility Checks + +**Applicable:** Yes / No + +_Skip this section if usability and accessibility are not relevant to the files under review. +If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| USE-01 | The feature or API is easy to use correctly | Pass / Fail / N/A | _Required if Fail or N/A_ | +| USE-02 | All public APIs are well documented | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.8 Test Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no test code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| TEST-01 | Tests cover expected (happy-path) behavior | Pass / Fail / N/A | _Required if Fail or N/A_ | +| TEST-02 | Tests cover error conditions and boundary cases | Pass / Fail / N/A | _Required if Fail or N/A_ | +| TEST-03 | Tests are independent and repeatable (no shared mutable state, no ordering dependency) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| TEST-04 | Test names clearly describe the behavior being verified | Pass / Fail / N/A | _Required if Fail or N/A_ | +| TEST-05 | New test cases are added for new functionality or defect fixes | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.9 Security Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files or if security concerns are not +relevant to the files under review. If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| SEC-01 | No obvious security vulnerabilities are present (e.g., injection flaws, hardcoded credentials) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| SEC-02 | Authentication and authorization are handled correctly (see design documentation) | Pass / Fail / N/A | _Required if Fail or N/A_ | +| SEC-03 | Sensitive data is stored and transmitted securely | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.10 Code Readability Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files. If not applicable, +record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| READ-01 | Code is easy to understand | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-02 | Methods and functions are small enough to be easily understood | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-03 | Symbols (variables, functions, classes) are well named | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-04 | Code is located in the correct place in the codebase | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-05 | Flow of control can be easily followed | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-06 | Data flow is understandable | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-07 | Comments are provided where the code is non-obvious | Pass / Fail / N/A | _Required if Fail or N/A_ | +| READ-08 | No debug artifacts or commented-out code have been left in the codebase | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.11 Requirements vs Documentation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no requirements files or no general technical documentation +files. If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| REQDOC-01 | All reviewed requirements are addressed in the general technical documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQDOC-02 | No reviewed requirement is contradicted by the general technical documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.12 Requirements vs Implementation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no requirements files or no source code +files. If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| REQIMP-01 | All requirements under review are addressed by the implementation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQIMP-02 | No requirement is contradicted by the implementation | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.13 Requirements vs Testing Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no requirements files or no test code files. +If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| REQTEST-01 | Every requirement under review is covered by at least one test | Pass / Fail / N/A | _Required if Fail or N/A_ | +| REQTEST-02 | Tests verify the behavior described in each requirement | Pass / Fail / N/A | _Required if Fail or N/A_ | + +### 2.14 Code vs Design Documentation Checks + +**Applicable:** Yes / No + +_Skip this section if the review contains no source code files or no design documentation +files. If not applicable, record the reason here:_ + +| # | Check | Outcome | Justification | +| :-- | :---- | :------ | :------------ | +| CODEDOC-01 | The code correctly implements the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODEDOC-02 | All public APIs and interfaces are documented in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODEDOC-03 | Non-obvious algorithms and significant design decisions are explained in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | +| CODEDOC-04 | No important code details are missing from the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | + +--- + +## 3. Conclusion + +### 3.1 Summary of Findings + +_List any checks recorded as Fail, and any observations that do not constitute a failure +but should be noted for the project record:_ + +| # | Check | Finding | +| :-- | :---- | :------ | +| _[check ID]_ | _[check description]_ | _[describe the finding]_ | + +### 3.2 Overall Outcome + +**Overall Outcome:** Pass / Fail + +_State the basis for the overall outcome, including any conditions or follow-up actions +required before the review can be considered approved:_ From a23684cf909c41777df9ace8840b884105d20f10 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:49:39 -0400 Subject: [PATCH 42/62] chore: remove accidentally committed review template download Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- review-template-downloaded.md | 266 ---------------------------------- 1 file changed, 266 deletions(-) delete mode 100644 review-template-downloaded.md diff --git a/review-template-downloaded.md b/review-template-downloaded.md deleted file mode 100644 index 744a522..0000000 --- a/review-template-downloaded.md +++ /dev/null @@ -1,266 +0,0 @@ -# _[Review Title]_ - -## 1. Introduction - -### 1.1 Purpose - -This document records the formal review of a set of project files. - -### 1.2 Scope - -_[Describe the scope of this review, including which project, release, or change is being reviewed.]_ - -### 1.3 Outcomes - -Each check must be recorded with one of the following outcomes: - -| Outcome | Meaning | -| :------ | :------ | -| Pass | The check was performed and the criterion is satisfied | -| Fail | The check was performed and the criterion is not satisfied | -| N/A | The check does not apply; justification is required | - -### 1.4 Review Details - -| Field | Value | -| :---- | :---- | -| Project | _[Project name]_ | -| Review ID | _[Review identifier]_ | -| Review Title | _[Review title]_ | -| Fingerprint | _[Fingerprint of the complete file set]_ | -| Review Date | _[YYYY-MM-DD]_ | - -### 1.5 Reviewers - -| Name | Role | Organization | Signature | Date | -| :--- | :--- | :----------- | :-------- | :--- | -| _[Reviewer name]_ | _[Role]_ | _[Organization]_ | _[Signature]_ | _[YYYY-MM-DD]_ | -| _[Reviewer name]_ | _[Role]_ | _[Organization]_ | _[Signature]_ | _[YYYY-MM-DD]_ | - -### 1.6 Files Under Review - -| File | -| :--- | -| _[filename]_ | - ---- - -## 2. Review Checklist - -### 2.1 Requirements Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no requirements files. If not applicable, -record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| REQ-01 | All requirements have a unique identifier | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQ-02 | All requirements are unambiguous (only one valid interpretation) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQ-03 | All requirements are testable (compliance can be demonstrated by a test) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQ-04 | All requirements are consistent (no requirement contradicts another) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQ-05 | All requirements are complete (no TBDs, undefined terms, or missing information) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQ-06 | All requirements are verifiable (can be objectively confirmed as met or not met) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQ-07 | No compound requirements are present (each requirement expresses a single testable criterion) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQ-08 | No requirements are missing (all expected behaviors and constraints are specified) | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.2 Design Documentation Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no design documentation files. If not applicable, -record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| DES-01 | Design documentation clearly describes the purpose of the component or feature | Pass / Fail / N/A | _Required if Fail or N/A_ | -| DES-02 | Design documentation covers the necessary implementation details | Pass / Fail / N/A | _Required if Fail or N/A_ | -| DES-03 | Design documentation describes how the code is interfaced (APIs, inputs, outputs) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| DES-04 | Design documentation describes the expected normal operation | Pass / Fail / N/A | _Required if Fail or N/A_ | -| DES-05 | Design documentation describes the expected error handling | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.3 Technical Documentation Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no general technical documentation files (e.g., user guides, -API references, README files, release notes). If not applicable, record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| DOC-01 | Documentation is free of technical inaccuracies | Pass / Fail / N/A | _Required if Fail or N/A_ | -| DOC-02 | Documentation is consistent with the current implementation and requirements | Pass / Fail / N/A | _Required if Fail or N/A_ | -| DOC-03 | All referenced external documents and dependencies are correctly identified | Pass / Fail / N/A | _Required if Fail or N/A_ | -| DOC-04 | Documentation is free of spelling and grammar errors | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.4 Code Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no source code files. If not applicable, -record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| CODE-01 | Code conforms to the project coding standards and style guide | Pass / Fail / N/A | _Required if Fail or N/A_ | -| CODE-02 | No obvious resource leaks are present (file handles, connections, memory) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| CODE-03 | No hardcoded values are present that should be configurable | Pass / Fail / N/A | _Required if Fail or N/A_ | -| CODE-04 | Each unit or function has a single, well-defined responsibility | Pass / Fail / N/A | _Required if Fail or N/A_ | -| CODE-05 | Code is written at the appropriate level of abstraction | Pass / Fail / N/A | _Required if Fail or N/A_ | -| CODE-06 | Code has an appropriate amount of extensibility for its context | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.5 Logic Error Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no source code files. If not applicable, -record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| LOGIC-01 | Code does only what is intended (no unintended side effects or behaviors) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| LOGIC-02 | All significant inputs and boundary conditions are handled correctly | Pass / Fail / N/A | _Required if Fail or N/A_ | -| LOGIC-03 | Concurrency and threading concerns are identified and addressed | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.6 Error Handling & Logging Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no source code files. If not applicable, -record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| ERR-01 | Error handling follows the approach described in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | -| ERR-02 | The logging volume and level of detail are appropriate | Pass / Fail / N/A | _Required if Fail or N/A_ | -| ERR-03 | Error messages are user-friendly and actionable | Pass / Fail / N/A | _Required if Fail or N/A_ | -| ERR-04 | Error messages and log entries do not leak sensitive data | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.7 Usability / Accessibility Checks - -**Applicable:** Yes / No - -_Skip this section if usability and accessibility are not relevant to the files under review. -If not applicable, record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| USE-01 | The feature or API is easy to use correctly | Pass / Fail / N/A | _Required if Fail or N/A_ | -| USE-02 | All public APIs are well documented | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.8 Test Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no test code files. If not applicable, -record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| TEST-01 | Tests cover expected (happy-path) behavior | Pass / Fail / N/A | _Required if Fail or N/A_ | -| TEST-02 | Tests cover error conditions and boundary cases | Pass / Fail / N/A | _Required if Fail or N/A_ | -| TEST-03 | Tests are independent and repeatable (no shared mutable state, no ordering dependency) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| TEST-04 | Test names clearly describe the behavior being verified | Pass / Fail / N/A | _Required if Fail or N/A_ | -| TEST-05 | New test cases are added for new functionality or defect fixes | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.9 Security Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no source code files or if security concerns are not -relevant to the files under review. If not applicable, record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| SEC-01 | No obvious security vulnerabilities are present (e.g., injection flaws, hardcoded credentials) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| SEC-02 | Authentication and authorization are handled correctly (see design documentation) | Pass / Fail / N/A | _Required if Fail or N/A_ | -| SEC-03 | Sensitive data is stored and transmitted securely | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.10 Code Readability Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no source code files. If not applicable, -record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| READ-01 | Code is easy to understand | Pass / Fail / N/A | _Required if Fail or N/A_ | -| READ-02 | Methods and functions are small enough to be easily understood | Pass / Fail / N/A | _Required if Fail or N/A_ | -| READ-03 | Symbols (variables, functions, classes) are well named | Pass / Fail / N/A | _Required if Fail or N/A_ | -| READ-04 | Code is located in the correct place in the codebase | Pass / Fail / N/A | _Required if Fail or N/A_ | -| READ-05 | Flow of control can be easily followed | Pass / Fail / N/A | _Required if Fail or N/A_ | -| READ-06 | Data flow is understandable | Pass / Fail / N/A | _Required if Fail or N/A_ | -| READ-07 | Comments are provided where the code is non-obvious | Pass / Fail / N/A | _Required if Fail or N/A_ | -| READ-08 | No debug artifacts or commented-out code have been left in the codebase | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.11 Requirements vs Documentation Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no requirements files or no general technical documentation -files. If not applicable, record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| REQDOC-01 | All reviewed requirements are addressed in the general technical documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQDOC-02 | No reviewed requirement is contradicted by the general technical documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.12 Requirements vs Implementation Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no requirements files or no source code -files. If not applicable, record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| REQIMP-01 | All requirements under review are addressed by the implementation | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQIMP-02 | No requirement is contradicted by the implementation | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.13 Requirements vs Testing Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no requirements files or no test code files. -If not applicable, record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| REQTEST-01 | Every requirement under review is covered by at least one test | Pass / Fail / N/A | _Required if Fail or N/A_ | -| REQTEST-02 | Tests verify the behavior described in each requirement | Pass / Fail / N/A | _Required if Fail or N/A_ | - -### 2.14 Code vs Design Documentation Checks - -**Applicable:** Yes / No - -_Skip this section if the review contains no source code files or no design documentation -files. If not applicable, record the reason here:_ - -| # | Check | Outcome | Justification | -| :-- | :---- | :------ | :------------ | -| CODEDOC-01 | The code correctly implements the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | -| CODEDOC-02 | All public APIs and interfaces are documented in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | -| CODEDOC-03 | Non-obvious algorithms and significant design decisions are explained in the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | -| CODEDOC-04 | No important code details are missing from the design documentation | Pass / Fail / N/A | _Required if Fail or N/A_ | - ---- - -## 3. Conclusion - -### 3.1 Summary of Findings - -_List any checks recorded as Fail, and any observations that do not constitute a failure -but should be noted for the project record:_ - -| # | Check | Finding | -| :-- | :---- | :------ | -| _[check ID]_ | _[check description]_ | _[describe the finding]_ | - -### 3.2 Overall Outcome - -**Overall Outcome:** Pass / Fail - -_State the basis for the overall outcome, including any conditions or follow-up actions -required before the review can be considered approved:_ From b408de5d4a4a48781aef8524a38ef5cc62aaf3ae Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:53:38 -0400 Subject: [PATCH 43/62] fix: rewrite DemaConsultingTestResults requirements as observable behavior Remove internal type names (TestResults, TestResult, TrxSerializer, JUnitSerializer) from requirement titles; express only observable behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ots/dema-consulting-test-results.yaml | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/reqstream/ots/dema-consulting-test-results.yaml b/docs/reqstream/ots/dema-consulting-test-results.yaml index 1057ddc..fbaa9ed 100644 --- a/docs/reqstream/ots/dema-consulting-test-results.yaml +++ b/docs/reqstream/ots/dema-consulting-test-results.yaml @@ -7,8 +7,8 @@ sections: requirements: - id: DemaConsultingTestResults-Results-RecordTestOutcomes title: >- - DemaConsulting.TestResults shall provide a TestResults container and - TestResult record for aggregating individual test outcomes. + DemaConsulting.TestResults shall support collecting individual test case + outcomes into a reportable collection. justification: | The SelfTest subsystem must collect pass/fail evidence for each self-validation scenario and report totals to the user. DemaConsulting.TestResults provides the @@ -17,10 +17,8 @@ sections: tests: [Validation_Run_WritesVersionAndHelpTestResults] - id: DemaConsultingTestResults-Results-SerializeToTrx title: >- - DemaConsulting.TestResults shall serialize a TestResults collection - to TRX format - via TrxSerializer so that self-validation results can be consumed by - CI test + DemaConsulting.TestResults shall serialize a collected test results set + to TRX format so that self-validation results can be consumed by CI test reporting tools. justification: | CI pipelines and test dashboards commonly consume TRX format for result aggregation @@ -29,10 +27,8 @@ sections: tests: [Validation_Run_WithResultsTrxFile_CreatesTrxFile] - id: DemaConsultingTestResults-Results-SerializeToJUnit title: >- - DemaConsulting.TestResults shall serialize a TestResults collection - to JUnit XML - format via JUnitSerializer so that self-validation results can be consumed - by + DemaConsulting.TestResults shall serialize a collected test results set + to JUnit XML format so that self-validation results can be consumed by non-Microsoft CI tools. justification: | JUnit XML is the de-facto standard for test result interchange in many CI systems From f2bfdb1f84f60260800e9c057e8643f78415ecec Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 21:57:31 -0400 Subject: [PATCH 44/62] fix: add JUnit content assertion to match TRX test pattern Add Assert.Contains('testsuites') check to the JUnit results test and update verification doc to describe format-specific evidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ots/dema-consulting-test-results.md | 18 +++++++++--------- .../SelfTest/ValidationTests.cs | 4 +++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/verification/ots/dema-consulting-test-results.md b/docs/verification/ots/dema-consulting-test-results.md index ec11852..d6b5c37 100644 --- a/docs/verification/ots/dema-consulting-test-results.md +++ b/docs/verification/ots/dema-consulting-test-results.md @@ -5,10 +5,10 @@ DemaConsulting.TestResults is verified in ApiMark through the existing self-validation tests in `test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs` that exercise the pass/fail recording and results-file writing paths of `Validation.cs`. The verification focus is the subset of capabilities -needed by the product: creating a `TestResults` collection, recording test outcomes on individual -`TestResult` objects, and serializing the collection to TRX and JUnit XML via `TrxSerializer` and -`JUnitSerializer`. Evidence is collected from automated tests that confirm test names appear in log -output and that results files are created at the specified paths. +needed by the product: collecting test outcomes into a reportable collection and serializing that +collection to TRX and JUnit XML. Evidence is collected from automated tests that confirm test names +appear in log output and that results files are created at the specified paths with format-specific +content. ### Test Scenarios @@ -18,11 +18,11 @@ and `ApiMark_HelpDisplay`. This scenario is tested by `Validation_Run_WritesVersionAndHelpTestResults`. **Results collection serializes to TRX when requested**: Verifies that when `context.ResultsFile` -has a `.trx` extension, `WriteResultsFile` calls `TrxSerializer.Serialize` and writes the result -to the specified path. This scenario is tested by +has a `.trx` extension, a `.trx` file is written to the specified path containing TRX XML content +(identified by the `TestRun` root element). This scenario is tested by `Validation_Run_WithResultsTrxFile_CreatesTrxFile`. **Results collection serializes to JUnit XML when requested**: Verifies that when -`context.ResultsFile` has a `.xml` extension, `WriteResultsFile` calls `JUnitSerializer.Serialize` -and writes the result to the specified path. This scenario is tested by -`Validation_Run_WithResultsXmlFile_CreatesXmlFile`. +`context.ResultsFile` has a `.xml` extension, a `.xml` file is written to the specified path +containing JUnit XML content (identified by the `testsuites` root element). This scenario is tested +by `Validation_Run_WithResultsXmlFile_CreatesXmlFile`. diff --git a/test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs b/test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs index 64e0e62..5509e15 100644 --- a/test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs +++ b/test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs @@ -70,8 +70,10 @@ public void Validation_Run_WithResultsXmlFile_CreatesXmlFile() // Act Validation.Run(context); - // Assert: the .xml file must exist + // Assert: the .xml file must exist and contain JUnit XML content Assert.True(File.Exists(xmlPath), "XML results file must be created"); + var content = File.ReadAllText(xmlPath); + Assert.Contains("testsuites", content); } finally { From 4fd8f4092caf6d7ccdd2c6e507a1edfa8d6f23cd Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 22:01:30 -0400 Subject: [PATCH 45/62] fix: add serialization tests as corroborating evidence for RecordTestOutcomes TRX and JUnit tests prove the collection was populated; update verification approach to explain the chain of evidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/ots/dema-consulting-test-results.yaml | 5 ++++- docs/verification/ots/dema-consulting-test-results.md | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/reqstream/ots/dema-consulting-test-results.yaml b/docs/reqstream/ots/dema-consulting-test-results.yaml index fbaa9ed..24850b4 100644 --- a/docs/reqstream/ots/dema-consulting-test-results.yaml +++ b/docs/reqstream/ots/dema-consulting-test-results.yaml @@ -14,7 +14,10 @@ sections: scenario and report totals to the user. DemaConsulting.TestResults provides the structured container that aggregates individual test outcomes into a reportable collection. - tests: [Validation_Run_WritesVersionAndHelpTestResults] + tests: + - Validation_Run_WritesVersionAndHelpTestResults + - Validation_Run_WithResultsTrxFile_CreatesTrxFile + - Validation_Run_WithResultsXmlFile_CreatesXmlFile - id: DemaConsultingTestResults-Results-SerializeToTrx title: >- DemaConsulting.TestResults shall serialize a collected test results set diff --git a/docs/verification/ots/dema-consulting-test-results.md b/docs/verification/ots/dema-consulting-test-results.md index d6b5c37..33757de 100644 --- a/docs/verification/ots/dema-consulting-test-results.md +++ b/docs/verification/ots/dema-consulting-test-results.md @@ -6,9 +6,10 @@ DemaConsulting.TestResults is verified in ApiMark through the existing self-vali `test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs` that exercise the pass/fail recording and results-file writing paths of `Validation.cs`. The verification focus is the subset of capabilities needed by the product: collecting test outcomes into a reportable collection and serializing that -collection to TRX and JUnit XML. Evidence is collected from automated tests that confirm test names -appear in log output and that results files are created at the specified paths with format-specific -content. +collection to TRX and JUnit XML. The collection behavior is evidenced by three tests: the log test +confirms test names are recorded and emitted, while the TRX and JUnit serialization tests confirm +that a populated collection is successfully serialized (format-specific root elements `TestRun` and +`testsuites` can only appear if the collection was populated). ### Test Scenarios From bda97046395411379b5e1034a78f77d626dcb9ae Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 22:12:20 -0400 Subject: [PATCH 46/62] feat: round-trip TRX/JUnit deserialization in results file tests Replace string-match assertions with full round-trip: deserialize written file using TrxSerializer/JUnitSerializer and assert test names and outcomes. Add DemaConsulting.TestResults 1.7.0 to Tool.Tests project. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ots/dema-consulting-test-results.md | 18 +++++++++-------- .../ApiMark.Tool.Tests.csproj | 1 + .../SelfTest/ValidationTests.cs | 20 +++++++++++++------ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/verification/ots/dema-consulting-test-results.md b/docs/verification/ots/dema-consulting-test-results.md index 33757de..a102b53 100644 --- a/docs/verification/ots/dema-consulting-test-results.md +++ b/docs/verification/ots/dema-consulting-test-results.md @@ -7,9 +7,9 @@ DemaConsulting.TestResults is verified in ApiMark through the existing self-vali results-file writing paths of `Validation.cs`. The verification focus is the subset of capabilities needed by the product: collecting test outcomes into a reportable collection and serializing that collection to TRX and JUnit XML. The collection behavior is evidenced by three tests: the log test -confirms test names are recorded and emitted, while the TRX and JUnit serialization tests confirm -that a populated collection is successfully serialized (format-specific root elements `TestRun` and -`testsuites` can only appear if the collection was populated). +confirms test names are recorded and emitted, while the TRX and JUnit serialization tests perform +a full round-trip — deserializing the written file back into a `TestResults` collection and +asserting both test names and `Passed` outcomes are preserved. ### Test Scenarios @@ -19,11 +19,13 @@ and `ApiMark_HelpDisplay`. This scenario is tested by `Validation_Run_WritesVersionAndHelpTestResults`. **Results collection serializes to TRX when requested**: Verifies that when `context.ResultsFile` -has a `.trx` extension, a `.trx` file is written to the specified path containing TRX XML content -(identified by the `TestRun` root element). This scenario is tested by +has a `.trx` extension, a `.trx` file is written containing TRX XML that round-trips correctly: +the deserialized collection contains exactly two results (`ApiMark_VersionDisplay` and +`ApiMark_HelpDisplay`, both `Passed`). This scenario is tested by `Validation_Run_WithResultsTrxFile_CreatesTrxFile`. **Results collection serializes to JUnit XML when requested**: Verifies that when -`context.ResultsFile` has a `.xml` extension, a `.xml` file is written to the specified path -containing JUnit XML content (identified by the `testsuites` root element). This scenario is tested -by `Validation_Run_WithResultsXmlFile_CreatesXmlFile`. +`context.ResultsFile` has a `.xml` extension, a `.xml` file is written containing JUnit XML that +round-trips correctly: the deserialized collection contains exactly two results +(`ApiMark_VersionDisplay` and `ApiMark_HelpDisplay`, both `Passed`). This scenario is tested by +`Validation_Run_WithResultsXmlFile_CreatesXmlFile`. diff --git a/test/ApiMark.Tool.Tests/ApiMark.Tool.Tests.csproj b/test/ApiMark.Tool.Tests/ApiMark.Tool.Tests.csproj index 3791b4e..a384dc2 100644 --- a/test/ApiMark.Tool.Tests/ApiMark.Tool.Tests.csproj +++ b/test/ApiMark.Tool.Tests/ApiMark.Tool.Tests.csproj @@ -13,6 +13,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs b/test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs index 5509e15..d116036 100644 --- a/test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs +++ b/test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs @@ -1,5 +1,7 @@ using ApiMark.Tool.Cli; using ApiMark.Tool.SelfTest; +using DemaConsulting.TestResults; +using DemaConsulting.TestResults.IO; using Xunit; namespace ApiMark.Tool.Tests.SelfTest; @@ -39,10 +41,13 @@ public void Validation_Run_WithResultsTrxFile_CreatesTrxFile() // Act Validation.Run(context); - // Assert: the .trx file must exist and contain TRX XML content + // Assert: the .trx file must exist and round-trip both test names and outcomes Assert.True(File.Exists(trxPath), "TRX results file must be created"); - var content = File.ReadAllText(trxPath); - Assert.Contains("TestRun", content); + var trxContent = File.ReadAllText(trxPath); + var trxResults = TrxSerializer.Deserialize(trxContent); + Assert.Equal(2, trxResults.Results.Count); + Assert.Contains(trxResults.Results, r => r.Name == "ApiMark_VersionDisplay" && r.Outcome == TestOutcome.Passed); + Assert.Contains(trxResults.Results, r => r.Name == "ApiMark_HelpDisplay" && r.Outcome == TestOutcome.Passed); } finally { @@ -70,10 +75,13 @@ public void Validation_Run_WithResultsXmlFile_CreatesXmlFile() // Act Validation.Run(context); - // Assert: the .xml file must exist and contain JUnit XML content + // Assert: the .xml file must exist and round-trip both test names and outcomes Assert.True(File.Exists(xmlPath), "XML results file must be created"); - var content = File.ReadAllText(xmlPath); - Assert.Contains("testsuites", content); + var xmlContent = File.ReadAllText(xmlPath); + var xmlResults = JUnitSerializer.Deserialize(xmlContent); + Assert.Equal(2, xmlResults.Results.Count); + Assert.Contains(xmlResults.Results, r => r.Name == "ApiMark_VersionDisplay" && r.Outcome == TestOutcome.Passed); + Assert.Contains(xmlResults.Results, r => r.Name == "ApiMark_HelpDisplay" && r.Outcome == TestOutcome.Passed); } finally { From 065af2711c1b259e9640091f1af5aeac0d9f195e Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 22:21:52 -0400 Subject: [PATCH 47/62] docs: add error-handling section to DemaConsultingTestResults design doc Document unsupported-extension and I/O-exception paths in WriteResultsFile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/ots/dema-consulting-test-results.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/design/ots/dema-consulting-test-results.md b/docs/design/ots/dema-consulting-test-results.md index 18bf897..4812415 100644 --- a/docs/design/ots/dema-consulting-test-results.md +++ b/docs/design/ots/dema-consulting-test-results.md @@ -42,3 +42,13 @@ is introduced. The integration follows this sequence: serialized content with `File.WriteAllText`. The consuming unit is `ApiMarkTool-SelfTest-Validation`. + +### Error Handling + +If the caller supplies a results file path with an unsupported extension (anything other than +`.trx` or `.xml`), `WriteResultsFile` calls `context.WriteError()` with an explanatory message +and returns without writing a file; this sets `context.ExitCode` to 1 as a side-effect. + +If a file I/O or serialization exception occurs when writing the results file, the exception is +caught and reported via `context.WriteError()`, again resulting in a non-zero exit code. Exceptions +are not re-thrown; the caller receives the error through the exit code. From 92974a3e743f4ea560b7ef464c417182fbcd33c4 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 22:35:48 -0400 Subject: [PATCH 48/62] chore: fix all lint issues (pre-PR sweep) - Fix YAML syntax error in cpp-type-link-resolver.yaml (std:: in plain scalar) - Fix YAML comment warning in file-markdown-writer.yaml (# chars in plain scalar) - Add missing ApiMarkCore-IApiGenerator-DefineParseMethod requirement - Add missing ApiMarkCore-IMarkdownWriterFactory-SupportRootLevelFiles requirement - Add missing top-level ApiMarkVhdl-VhdlEmitter requirement - Fix British spellings: unrecognised->unrecognized, recognising->recognizing - Add technical terms to .cspell.yaml: linkifies, typeparamref, cout, headerless, sbyte, REQDOC, REQIMP, REQTEST, CODEDOC, modreq, getcount - Apply markdownlint auto-fix to docs/design/api-mark-cpp/cpp-generator.md --- .cspell.yaml | 11 +++++++++++ docs/design/api-mark-cpp/cpp-generator.md | 4 ++-- docs/design/api-mark-dot-net.md | 2 +- docs/design/api-mark-vhdl/vhdl-ast-parser.md | 2 +- .../api-mark-core/file-markdown-writer.yaml | 3 ++- .../api-mark-core/i-api-generator.yaml | 17 +++++++++++++++++ .../i-markdown-writer-factory.yaml | 15 +++++++++++++++ .../api-mark-cpp/cpp-type-link-resolver.yaml | 3 ++- docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml | 15 +++++++++++++++ 9 files changed, 66 insertions(+), 6 deletions(-) diff --git a/.cspell.yaml b/.cspell.yaml index 84e7925..5ff00f5 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -31,6 +31,8 @@ words: - ANTLR - GHDL - archs + - getcount + - headerless - hrefs - iface - interp @@ -44,12 +46,14 @@ words: - libclangsharp - libext - linkified + - linkifies - Linkification - Linkify - linkify - Linq - maxcount - misattributed + - modreq - msbuild - MSBuild - msvc @@ -67,18 +71,25 @@ words: - postconditions - renderable - repoint + - typeparamref + - REQDOC + - REQIMP + - REQTEST - reqstream - reviewmark - runtimeconfig - sarif - sarifmark + - sbyte - slnx - stdarg - stddef - sonar - sonarmark - subclassable + - CODEDOC - constexpr + - cout - cppreference - endcode - fparse diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index 0d4bd40..8f63a90 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -33,8 +33,8 @@ single-file or gradual-disclosure Markdown output. to clang for type resolution only. - `Defines`: `IReadOnlyList` — preprocessor definitions forwarded as `-D` arguments. -- `CppStandard`: `string` — language standard passed to clang; defaults to - `c++17`. +- `CppStandard`: `string` — language standard passed to clang (e.g. `c++17`, + `c++20`); defaults to `c++17`. - `ClangPath`: `string?` — optional explicit clang path. When null or empty, discovery falls back to `APIMARK_CLANG_PATH`, PATH, `xcrun clang`, and Windows LLVM discovery. diff --git a/docs/design/api-mark-dot-net.md b/docs/design/api-mark-dot-net.md index 95c5474..6ac236d 100644 --- a/docs/design/api-mark-dot-net.md +++ b/docs/design/api-mark-dot-net.md @@ -176,5 +176,5 @@ N/A - not a safety-classified software item. context)` throws `ArgumentNullException` if any of `factory`, `config`, or `context` is null. - **Malformed XML documentation**: `XmlDocReader` silently ignores malformed or missing - XML doc elements — unrecognised elements produce empty/null lookups rather than + XML doc elements — unrecognized elements produce empty/null lookups rather than exceptions, so generation continues with placeholder text where doc content is absent. diff --git a/docs/design/api-mark-vhdl/vhdl-ast-parser.md b/docs/design/api-mark-vhdl/vhdl-ast-parser.md index d9b602d..0824795 100644 --- a/docs/design/api-mark-vhdl/vhdl-ast-parser.md +++ b/docs/design/api-mark-vhdl/vhdl-ast-parser.md @@ -149,7 +149,7 @@ listener that accumulates syntax errors and throws rather than writing to when at least one error was recorded; no-op otherwise. **VhdlAstParser.ParseDocCommentLines** (private static): Parses a list of `--!` comment -lines into a `VhdlDocComment`, recognising `@brief`, `@param`, and `@return` tags. +lines into a `VhdlDocComment`, recognizing `@brief`, `@param`, and `@return` tags. - `@brief ` → `Summary` - `@param ` → `VhdlParamDoc` entry in `Params` diff --git a/docs/reqstream/api-mark-core/file-markdown-writer.yaml b/docs/reqstream/api-mark-core/file-markdown-writer.yaml index d0e6364..77718bd 100644 --- a/docs/reqstream/api-mark-core/file-markdown-writer.yaml +++ b/docs/reqstream/api-mark-core/file-markdown-writer.yaml @@ -6,7 +6,8 @@ sections: - title: FileMarkdownWriter Requirements requirements: - id: ApiMarkCore-FileMarkdownWriter-WriteHeadingAsAtxSyntax - title: FileMarkdownWriter shall write ATX-style headings using the + title: >- + FileMarkdownWriter shall write ATX-style headings using the correct number of # characters for the requested heading level. justification: | Generated Markdown files use headings to provide navigable structure. diff --git a/docs/reqstream/api-mark-core/i-api-generator.yaml b/docs/reqstream/api-mark-core/i-api-generator.yaml index bde2b53..aef5734 100644 --- a/docs/reqstream/api-mark-core/i-api-generator.yaml +++ b/docs/reqstream/api-mark-core/i-api-generator.yaml @@ -5,6 +5,23 @@ sections: sections: - title: IApiGenerator Requirements requirements: + - id: ApiMarkCore-IApiGenerator-DefineParseMethod + title: >- + IApiGenerator shall define a Parse method that accepts an IContext + and returns an IApiEmitter representing the parsed component's + documentation. + justification: | + The Parse method is the entry point for the first stage of the + two-stage pipeline. Its signature must be uniform across all + language implementations so that the CLI and MSBuild task can + invoke any generator without knowing its concrete type. + children: + - ApiMarkCore-IApiGenerator-AcceptContextParameter + - ApiMarkCore-IApiGenerator-ReturnIApiEmitter + - ApiMarkCore-IApiGenerator-RejectNullContext + tests: + - IApiGenerator_Parse_WithMinimalStub_ExecutesSuccessfully + - IApiGenerator_Parse_NullContext_ThrowsArgumentNullException - id: ApiMarkCore-IApiGenerator-AcceptContextParameter title: IApiGenerator.Parse shall accept an IContext parameter as the caller-supplied diagnostic channel for informational and error output. diff --git a/docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml b/docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml index 97f7250..7de212b 100644 --- a/docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml +++ b/docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml @@ -13,6 +13,21 @@ sections: the storage mechanism so that the same generator code can write to disk in production and to memory in tests. tests: [IMarkdownWriterFactory_HasCreateMarkdown_Method] + - id: ApiMarkCore-IMarkdownWriterFactory-SupportRootLevelFiles + title: >- + IMarkdownWriterFactory.CreateMarkdown shall support creating root-level + files when subFolder is empty or whitespace. + justification: | + The api.md entrypoint file must always be created at the root of the + output tree, so the factory contract must permit an empty or whitespace + subFolder value to indicate the root directory. + children: + - ApiMarkCore-IMarkdownWriterFactory-AcceptEmptySubFolderAsRoot + - ApiMarkCore-IMarkdownWriterFactory-AcceptWhitespaceSubFolderAsRoot + tests: + - IMarkdownWriterFactory_CreateMarkdown_EmptySubFolder_IsRootLevel + - FileMarkdownWriterFactory_CreateMarkdown_RootLevel_CreatesFile + - FileMarkdownWriterFactory_CreateMarkdown_WhitespaceSubFolder_CreatesRootLevelFile - id: ApiMarkCore-IMarkdownWriterFactory-AcceptEmptySubFolderAsRoot title: IMarkdownWriterFactory shall create a root-level file when subFolder is an empty string. diff --git a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml index 7b54b8a..b23dfb7 100644 --- a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml @@ -58,7 +58,8 @@ sections: tests: - CppTypeLinkResolver_Linkify_PrimitiveType_ReturnsUnchanged - id: ApiMarkCpp-CppTypeLinkResolver-LinkifyStdType - title: CppTypeLinkResolver.Linkify shall leave std:: types unchanged. + title: >- + CppTypeLinkResolver.Linkify shall leave std:: types unchanged. justification: | Standard-library types are outside the generated documentation tree. tests: diff --git a/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml b/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml index 73d3a61..b747438 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-emitter.yaml @@ -5,6 +5,21 @@ sections: sections: - title: VhdlEmitter Requirements requirements: + - id: ApiMarkVhdl-VhdlEmitter + title: >- + VhdlEmitter shall implement IApiEmitter and dispatch Markdown + generation to the appropriate format-specific emitter. + justification: | + VhdlEmitter acts as the central dispatcher for both + gradual-disclosure and single-file VHDL output. Separating + the dispatch from format-specific logic allows each format + emitter to be tested in isolation while keeping the IApiEmitter + contract uniform across all language modules. + children: + - ApiMarkVhdl-VhdlEmitter-ImplementsIApiEmitter + tests: + - VhdlEmitter_Emit_GradualDisclosureFormat_ProducesMultipleOutputFiles + - VhdlEmitter_Emit_SingleFileFormat_ProducesSingleOutputFile - id: ApiMarkVhdl-VhdlEmitter-ImplementsIApiEmitter title: VhdlEmitter shall implement the IApiEmitter interface. justification: | From 8f12820c4828dac1ae30a734ebf2b82d16546dbf Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 23:29:29 -0400 Subject: [PATCH 49/62] fix: reject --depth > 3 for all formats; fix asymmetric property accessor accessibility Bug 1: Context.cs only guarded --depth > 3 when --format single-file was active. Supplying --depth 4 with the default gradual-disclosure format passed CLI validation, reached EmitConfig.HeadingDepth = 4, and threw a cryptic ArgumentOutOfRangeException from the setter. Add an unconditional guard that rejects any value above 3 with a clear error for every format, before the existing single-file-specific check. Bug 2: BuildPropertyAccessors (and BuildPropertySignature) used GetAccessibilityKeyword(prop.GetMethod ?? prop.SetMethod!) which always picks the getter when present. For a public property with a restricted getter (e.g. protected get; set;) this produced the wrong property-level keyword (protected) and emitted contradictory output such as 'protected Foo { get; public set; }' instead of 'public Foo { protected get; set; }'. Add a MostPermissiveAccessor(MethodDefinition? a, MethodDefinition? b) helper backed by an AccessibilityRank scorer, and use it in both call sites so that the property-level keyword always matches the most permissive accessor. Tests added: - Context_Create_WithDepth4_ThrowsArgumentException (Tool.Tests) - DotNetEmitter_BuildPropertyAccessors_AsymmetricGetSet_UsesMostPermissiveAccessibility (DotNet.Tests) - Fixture class AsymmetricAccessorClass with public { protected get; set; } property Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.DotNet/DotNetEmitter.cs | 60 ++++++++++++++++++- src/ApiMark.Tool/Cli/Context.cs | 10 ++++ .../AsymmetricAccessorClass.cs | 19 ++++++ .../DotNetEmitterTests.cs | 21 +++++++ test/ApiMark.Tool.Tests/Cli/ContextTests.cs | 15 +++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 test/ApiMark.DotNet.Fixtures/AsymmetricAccessorClass.cs diff --git a/src/ApiMark.DotNet/DotNetEmitter.cs b/src/ApiMark.DotNet/DotNetEmitter.cs index efd4e5c..c6270fc 100644 --- a/src/ApiMark.DotNet/DotNetEmitter.cs +++ b/src/ApiMark.DotNet/DotNetEmitter.cs @@ -687,7 +687,7 @@ internal static string BuildPropertySignature(PropertyDefinition prop, string co prop.PropertyType, contextNamespace, HasNullableAnnotation(prop.CustomAttributes)); - var accessibility = GetAccessibilityKeyword(prop.GetMethod ?? prop.SetMethod!); + var accessibility = GetAccessibilityKeyword(MostPermissiveAccessor(prop.GetMethod, prop.SetMethod)!); var accessors = BuildPropertyAccessors(prop); return $"{accessibility} {typeName} {prop.Name} {{ {accessors} }}"; } @@ -706,7 +706,10 @@ internal static string BuildPropertySignature(PropertyDefinition prop, string co internal static string BuildPropertyAccessors(PropertyDefinition prop) { var parts = new List(); - var propertyAccessibility = GetAccessibilityKeyword(prop.GetMethod ?? prop.SetMethod!); + + // Determine the property-level accessibility from the most permissive accessor so that + // a "public … { protected get; set; }" property renders as "public" — not "protected" + var propertyAccessibility = GetAccessibilityKeyword(MostPermissiveAccessor(prop.GetMethod, prop.SetMethod)!); if (prop.GetMethod != null) { @@ -815,6 +818,59 @@ internal static string BuildEventSignature(EventDefinition evt, string contextNa internal static string GetAccessibilityKeyword(EventDefinition evt) => evt.AddMethod != null ? GetAccessibilityKeyword(evt.AddMethod) : "private"; + /// + /// Returns whichever of two optional method definitions has the higher (more permissive) accessibility. + /// + /// + /// Used by and to derive + /// the property-level accessibility from its accessors. C# requires the property's declared + /// visibility to match the most permissive accessor; a public { protected get; set; } + /// property has set as the governing accessor (public), while get is less permissive + /// (protected) and therefore receives an explicit per-accessor prefix. + /// + /// First accessor, or when absent. + /// Second accessor, or when absent. + /// + /// The accessor with the higher accessibility rank. Returns the non-null operand when one + /// is ; behaviour is undefined when both are . + /// + private static MethodDefinition MostPermissiveAccessor(MethodDefinition? a, MethodDefinition? b) + { + if (a == null) + { + return b!; + } + + if (b == null) + { + return a; + } + + // Return the accessor whose accessibility rank is higher (i.e., more permissive) + return AccessibilityRank(a) >= AccessibilityRank(b) ? a : b; + } + + /// + /// Returns an integer rank for a method's accessibility, where higher values are more permissive. + /// + /// + /// Ordering mirrors the C# specification: public (5) > protected internal (4) > + /// internal (3) > protected (2) > private protected (1) > private (0). + /// Used exclusively by to select the governing + /// accessor for a property. + /// + /// The method whose accessibility rank to compute. + /// An integer rank in [0, 5] where 5 is most permissive (public). + private static int AccessibilityRank(MethodDefinition method) => method switch + { + { IsPublic: true } => 5, + { IsFamilyOrAssembly: true } => 4, + { IsAssembly: true } => 3, + { IsFamily: true } => 2, + { IsFamilyAndAssembly: true } => 1, + _ => 0, + }; + /// /// Builds the full display name for a method overload, including the simplified parameter /// type list in parentheses (e.g. Process(int, string)). diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index 1b12fec..99e3a4f 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -469,6 +469,16 @@ private int ParseArgument(string arg, string[] args, int index) case "--depth": HeadingDepth = GetRequiredIntArgument(arg, args, index, "a heading depth argument", 1, 6); + + // Reject depth > 3 for all formats — EmitConfig.HeadingDepth enforces a maximum of 3 + // because member headings are always written at depth+3, which would exceed H6 + if (HeadingDepth > 3) + { + throw new ArgumentException( + $"'--depth' value must be 1–3 (member headings are written at depth+3, which would exceed H6); got {HeadingDepth}.", + nameof(args)); + } + if (Format == OutputFormat.SingleFile && HeadingDepth > 3) { throw new ArgumentException( diff --git a/test/ApiMark.DotNet.Fixtures/AsymmetricAccessorClass.cs b/test/ApiMark.DotNet.Fixtures/AsymmetricAccessorClass.cs new file mode 100644 index 0000000..ef7c1b2 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/AsymmetricAccessorClass.cs @@ -0,0 +1,19 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.DotNet.Fixtures; + +/// A class with an asymmetric property accessor for testing property-level accessibility selection. +/// +/// Used by DotNetEmitter_BuildPropertyAccessors_AsymmetricGetSet_UsesMostPermissiveAccessibility +/// to verify that BuildPropertyAccessors derives the property-level accessibility keyword from +/// the most permissive accessor rather than always defaulting to the getter. +/// The property is declared public, but its getter is restricted to protected; the setter +/// is public (no explicit modifier, matching the property level). Correct output must therefore +/// use public as the property keyword and prefix only the getter with protected. +/// +public class AsymmetricAccessorClass +{ + /// Gets (protected) or sets (public) the value used to exercise asymmetric accessor rendering. + public int AsymmetricProperty { protected get; set; } +} diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs index 60e2157..fa5966a 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs @@ -269,4 +269,25 @@ public void DotNetEmitter_BuildPropertyAccessors_ProtectedProperty_DoesNotPrefix // Assert: both accessors share the property's protected accessibility — no prefix should be emitted Assert.Equal("get; set;", result); } + + /// + /// Validates that uses the most permissive accessor + /// to derive the property-level accessibility keyword when getter and setter have different levels. + /// + [Fact] + public void DotNetEmitter_BuildPropertyAccessors_AsymmetricGetSet_UsesMostPermissiveAccessibility() + { + // Arrange: load AsymmetricProperty from AsymmetricAccessorClass; getter is protected, setter is public. + // The most permissive accessor is the setter (public), so the property-level keyword must be "public" + // and only the getter should receive an explicit "protected " prefix. + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var type = assembly.MainModule.Types.Single(t => t.Name == "AsymmetricAccessorClass"); + var prop = type.Properties.Single(p => p.Name == "AsymmetricProperty"); + + // Act + var result = DotNetEmitter.BuildPropertyAccessors(prop); + + // Assert: getter must be prefixed with its restricted accessibility; setter must have no prefix + Assert.Equal("protected get; set;", result); + } } diff --git a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs index 5ec0459..cfacbab 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -721,6 +721,21 @@ public void Context_OpenLogFile_ErrorOutputAlsoWrittenToLog() } } + /// + /// Validates that --depth 4 without any format flag throws + /// because EmitConfig.HeadingDepth accepts a maximum of 3 for all output formats. + /// + [Fact] + public void Context_Create_WithDepth4_ThrowsArgumentException() + { + // Arrange: supply --depth 4 with no format flag — gradual-disclosure is the default format + // and must also reject depth > 3 (member headings at depth+3 would exceed H6) + var args = new[] { "--depth", "4" }; + + // Act / Assert: depth > 3 must throw ArgumentException for any format + Assert.Throws(() => Context.Create(args)); + } + /// /// Validates that --depth values above 3 throw /// when --format single-file is specified, regardless of argument order. From 8b456e230a5aee9106cf2c0fb125914c88075c96 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 23:35:03 -0400 Subject: [PATCH 50/62] fix: correct CppTypeLinkResolver replacement for qualified template args Replace LastIndexOf('::', cppTypeString) approach with a search bounded before the first '<', preventing corruption when a template argument is a qualified name sharing a prefix with the type being linked (e.g. ns::Foo was producing ns::Foo). Add regression test: QualifiedTypeWithQualifiedTemplateArgSharingPrefix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 25 ++++++----------- .../CppTypeLinkResolverTests.cs | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index 4343048..de8b984 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -152,23 +152,14 @@ public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) : stripped; var linked = $"[{shortName}]({relativePath})"; - // Position-aware single-site replacement: locate shortName at the exact token - // position to avoid corrupting template arguments that share a prefix with it - // (e.g. Foo where shortName = "Foo" must not also linkify "FooBar") - int startIdx; - if (stripped.Contains("::", StringComparison.Ordinal)) - { - // For qualified types, find the last '::' in the original string so we - // start searching only where the unqualified name begins - var lastColonPos = cppTypeString.LastIndexOf("::", StringComparison.Ordinal); - startIdx = lastColonPos >= 0 ? lastColonPos + 2 : 0; - } - else - { - startIdx = 0; - } - - var idx = cppTypeString.IndexOf(shortName, startIdx, StringComparison.Ordinal); + // Position-aware single-site replacement: search for shortName only in the + // portion of cppTypeString before the first '<'. This prevents looking inside + // template arguments — which may contain qualified names sharing a prefix with + // shortName (e.g. ns::Foo must not linkify FooBar instead of Foo). + // LastIndexOf within that slice naturally lands on the correct token site. + var ltPos = cppTypeString.IndexOf('<', StringComparison.Ordinal); + var searchRange = ltPos >= 0 ? cppTypeString[..ltPos] : cppTypeString; + var idx = searchRange.LastIndexOf(shortName, StringComparison.Ordinal); if (idx < 0) { // Fallback: should never happen in practice, but avoid silent corruption diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs index 30a88f8..496b53a 100644 --- a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -229,6 +229,33 @@ public void CppTypeLinkResolver_Linkify_QualifiedTypeWithSameNamePrefixInTemplat Assert.EndsWith("", result, StringComparison.Ordinal); } + /// + /// Validates that Linkify correctly resolves a qualified intra-library type whose + /// template argument is also a qualified name sharing the same namespace prefix, + /// without corrupting the template argument. + /// + [Fact] + public void CppTypeLinkResolver_Linkify_QualifiedTypeWithQualifiedTemplateArgSharingPrefix_EmitsLinkWithoutCorruption() + { + // Arrange: Foo is a known intra-library type; ns::FooBar is a qualified template + // argument NOT in knownTypes — the old LastIndexOf("::", cppTypeString) approach + // would find the '::' inside the template arg and corrupt "FooBar" into "[Foo](...)Bar" + var knownTypes = new Dictionary(StringComparer.Ordinal) + { + { "ns::Foo", "ns/Foo" }, + }; + var resolver = new CppTypeLinkResolver(knownTypes); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("ns::Foo", "ns", externalTypes); + + // Assert: "Foo" is linked but the template argument "FooBar" is not corrupted + Assert.Contains("[Foo](", result, StringComparison.Ordinal); + Assert.DoesNotContain("[FooBar]", result, StringComparison.Ordinal); + Assert.EndsWith("", result, StringComparison.Ordinal); + } + /// Validates that the constructor throws when knownTypes is null. [Fact] public void CppTypeLinkResolver_Constructor_NullKnownTypes_ThrowsArgumentNullException() From c76adde1a13987b0ff19b13026490103aba164f7 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 23:52:09 -0400 Subject: [PATCH 51/62] fix: tighten --depth max to 3 and make FailingApiMarkTask environment-independent - Change GetRequiredIntArgument upper bound from 6 to 3 so '--depth 7' reports the correct valid range (1-3) instead of the misleading 1-6 - Override ResolveDotNetExe in FailingApiMarkTask to return 'dummy-dotnet', ensuring the non-zero exit-code test always reaches RunToolProcess regardless of whether dotnet is on PATH Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Tool/Cli/Context.cs | 2 +- test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index 99e3a4f..acb64de 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -468,7 +468,7 @@ private int ParseArgument(string arg, string[] args, int index) return index + 1; case "--depth": - HeadingDepth = GetRequiredIntArgument(arg, args, index, "a heading depth argument", 1, 6); + HeadingDepth = GetRequiredIntArgument(arg, args, index, "a heading depth argument", 1, 3); // Reject depth > 3 for all formats — EmitConfig.HeadingDepth enforces a maximum of 3 // because member headings are always written at depth+3, which would exceed H6 diff --git a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs index 55fbcb2..4048a44 100644 --- a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs +++ b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs @@ -764,6 +764,9 @@ public void ApiMarkTask_Execute_WithMultipleOutputs_RunsToolForEachOutput() /// private sealed class FailingApiMarkTask : ApiMarkTask { + /// + protected override string? ResolveDotNetExe() => "dummy-dotnet"; + /// protected override bool RunToolProcess(string dotnetExe, IReadOnlyList toolArgs) { From 136c52ce08b18a223a90be5da582bb79ef65ab5b Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 25 Jun 2026 23:56:19 -0400 Subject: [PATCH 52/62] fix: correct British spelling 'behaviour' to 'behavior' in DotNetEmitter.cs --- src/ApiMark.DotNet/DotNetEmitter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ApiMark.DotNet/DotNetEmitter.cs b/src/ApiMark.DotNet/DotNetEmitter.cs index c6270fc..7608733 100644 --- a/src/ApiMark.DotNet/DotNetEmitter.cs +++ b/src/ApiMark.DotNet/DotNetEmitter.cs @@ -832,7 +832,7 @@ internal static string GetAccessibilityKeyword(EventDefinition evt) => /// Second accessor, or when absent. /// /// The accessor with the higher accessibility rank. Returns the non-null operand when one - /// is ; behaviour is undefined when both are . + /// is ; behavior is undefined when both are . /// private static MethodDefinition MostPermissiveAccessor(MethodDefinition? a, MethodDefinition? b) { From bc6ff3ab05525b355624f963570da07d11b1eab7 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 00:08:08 -0400 Subject: [PATCH 53/62] refactor: remove unreachable HeadingDepth>3 guards from Context.cs GetRequiredIntArgument now enforces max=3 directly so the subsequent if-HeadingDepth>3 blocks can never be reached. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Tool/Cli/Context.cs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index acb64de..8886545 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -469,23 +469,6 @@ private int ParseArgument(string arg, string[] args, int index) case "--depth": HeadingDepth = GetRequiredIntArgument(arg, args, index, "a heading depth argument", 1, 3); - - // Reject depth > 3 for all formats — EmitConfig.HeadingDepth enforces a maximum of 3 - // because member headings are always written at depth+3, which would exceed H6 - if (HeadingDepth > 3) - { - throw new ArgumentException( - $"'--depth' value must be 1–3 (member headings are written at depth+3, which would exceed H6); got {HeadingDepth}.", - nameof(args)); - } - - if (Format == OutputFormat.SingleFile && HeadingDepth > 3) - { - throw new ArgumentException( - $"'--depth' value must be 1–3 when '--format single-file' is used (member headings are at depth+3); got {HeadingDepth}.", - nameof(args)); - } - return index + 1; case "--format": From cae65cf0d22b4d6b067b1e375329c3f7fcfa6cae Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 00:09:38 -0400 Subject: [PATCH 54/62] docs: justify StringComparer.Ordinal in GlobFileCollector design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paths from Matcher.GetResultsInFullPath already reflect actual on-disk casing, so Ordinal is correct and filesystem-agnostic — no two distinct physical files produce ordinal-equal paths, and case-distinct paths on Linux are not incorrectly collapsed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-core/glob-file-collector.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/design/api-mark-core/glob-file-collector.md b/docs/design/api-mark-core/glob-file-collector.md index c0162b9..f411f0f 100644 --- a/docs/design/api-mark-core/glob-file-collector.md +++ b/docs/design/api-mark-core/glob-file-collector.md @@ -71,6 +71,13 @@ matched file paths from the collected set. `bool isExclusion`. - *Algorithm*: when `isExclusion` is true, calls `collected.Remove` for each full path; otherwise calls `collected.Add`. +- *Comparer*: `collected` uses `StringComparer.Ordinal`. Paths returned by + `Matcher.GetResultsInFullPath` reflect the actual on-disk casing (the + underlying OS resolves the real name), so no two distinct physical files + can produce identical ordinal-equal paths. Ordinal comparison is therefore + both correct and filesystem-agnostic: it deduplicates genuinely identical + paths on all platforms without incorrectly collapsing case-distinct paths + that are physically distinct files on case-sensitive filesystems (Linux). **GlobFileCollector.ParsePattern** (private static): Splits a pattern body into a filesystem root and a glob tail. From 40e7f80c289b85093d60da821e6c2f4a03c079cc Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 07:12:33 -0400 Subject: [PATCH 55/62] fix: correct TypeNameSimplifier test name in MonoCecil OTS requirements TypeNameSimplifier_Simplify_GenericArguments_AreSimplifiedRecursively was referenced as TypeNameSimplifier_GenericArguments_AreSimplifiedRecursively (missing '_Simplify_' segment), causing reqstream to report an unsatisfied requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/ots/mono-cecil.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reqstream/ots/mono-cecil.yaml b/docs/reqstream/ots/mono-cecil.yaml index b17381c..d6b6f32 100644 --- a/docs/reqstream/ots/mono-cecil.yaml +++ b/docs/reqstream/ots/mono-cecil.yaml @@ -35,7 +35,7 @@ sections: markdown output and to produce dedicated detail pages for every visible member. tests: - ApiMarkDotNet_TypeNames_CommonSignatures_RenderReadably - - TypeNameSimplifier_GenericArguments_AreSimplifiedRecursively + - TypeNameSimplifier_Simplify_GenericArguments_AreSimplifiedRecursively - DotNetGenerator_Generate_MethodWithParameters_CreatesMemberPage - id: MonoCecil-Assembly-ExposeAccessibilityModifiers title: Mono.Cecil shall expose the accessibility modifier for every type From 7f5d65fde2f70e777f9b571531397bbeb0047ec2 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 07:29:31 -0400 Subject: [PATCH 56/62] fix: normalize literal paths to on-disk casing in GlobFileCollector Add ResolveOnDiskPath helper that uses Directory.GetFiles to resolve the actual filesystem entry name, ensuring literal path keys are casing-consistent with paths returned by Matcher.GetResultsInFullPath. This makes exclusion removal correct on case-insensitive filesystems (Windows/macOS) when the caller-supplied casing differs from on-disk. Update design doc to document ResolveOnDiskPath and the Ordinal/casing rationale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-mark-core/glob-file-collector.md | 15 +++++++ src/ApiMark.Core/GlobFileCollector.cs | 42 ++++++++++++++++--- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/design/api-mark-core/glob-file-collector.md b/docs/design/api-mark-core/glob-file-collector.md index f411f0f..9997774 100644 --- a/docs/design/api-mark-core/glob-file-collector.md +++ b/docs/design/api-mark-core/glob-file-collector.md @@ -78,6 +78,21 @@ matched file paths from the collected set. both correct and filesystem-agnostic: it deduplicates genuinely identical paths on all platforms without incorrectly collapsing case-distinct paths that are physically distinct files on case-sensitive filesystems (Linux). + Literal paths are normalized to on-disk casing via `ResolveOnDiskPath` + before being added or removed, ensuring consistency with glob results. + +**GlobFileCollector.ResolveOnDiskPath** (private static): Resolves a +caller-supplied literal file path to its actual on-disk casing. + +- *Parameters*: `string literalPath` — the caller-supplied absolute path. +- *Returns*: the absolute path with on-disk casing, or `null` if the file + does not exist or the path is malformed. +- *Algorithm*: splits `literalPath` into directory and filename, then calls + `Directory.GetFiles(directory, fileName)` which asks the OS to resolve the + real entry name. On case-insensitive filesystems (Windows, macOS) this + corrects the casing to match what the OS stores; on case-sensitive + filesystems (Linux) it is equivalent to a guarded existence check with + exact case. **GlobFileCollector.ParsePattern** (private static): Splits a pattern body into a filesystem root and a glob tail. diff --git a/src/ApiMark.Core/GlobFileCollector.cs b/src/ApiMark.Core/GlobFileCollector.cs index 42d65b2..5dadd73 100644 --- a/src/ApiMark.Core/GlobFileCollector.cs +++ b/src/ApiMark.Core/GlobFileCollector.cs @@ -98,11 +98,15 @@ public static IReadOnlyList Collect( if (globTail.Length == 0) { - // No glob portion — root is a literal path; select it if it exists as a file - // and its extension is in the allowed set (same gate applied to glob results) - if (File.Exists(root) && extensions.Contains(Path.GetExtension(root))) + // No glob portion — root is a literal path; resolve on-disk casing so + // the path key is consistent with paths returned by Matcher.GetResultsInFullPath + if (extensions.Contains(Path.GetExtension(root))) { - AccumulateResults(collected, [Path.GetFullPath(root)], isExclusion); + var onDisk = ResolveOnDiskPath(root); + if (onDisk != null) + { + AccumulateResults(collected, [onDisk], isExclusion); + } } continue; @@ -164,9 +168,35 @@ private static void AccumulateResults(HashSet collected, IEnumerable - /// Returns when the final path segment of the glob tail - /// is exactly *, indicating that language-extension filtering should apply. + /// Resolves the on-disk path for a literal file path, correcting the casing to + /// match the actual filesystem entry. /// + /// + /// On case-insensitive filesystems (Windows, macOS) the caller-supplied casing may + /// differ from the entry stored on disk. Using + /// asks the OS to resolve the real + /// name, producing a path whose casing is consistent with the paths returned by + /// . On case-sensitive filesystems (Linux) + /// this is equivalent to a guarded existence check with exact case. + /// + /// The caller-supplied absolute file path to resolve. + /// + /// The absolute path with on-disk casing when the file exists; + /// when the file does not exist or the path is malformed. + /// + private static string? ResolveOnDiskPath(string literalPath) + { + var directory = Path.GetDirectoryName(literalPath); + var fileName = Path.GetFileName(literalPath); + if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) + { + return null; + } + + var matches = Directory.GetFiles(directory, fileName); + return matches.Length > 0 ? matches[0] : null; + } + /// /// The final segment is determined by splitting the glob tail on both forward and /// backward slashes. A segment of ** or *.ext does not trigger From f3ab90a3ac671b59f55d2ca67dbe2f79e31dee06 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 07:46:27 -0400 Subject: [PATCH 57/62] refactor: separate --depth validation between Context and RunToolLogic Context now validates --depth in the range 1-6 only (first-principles CLI validation); the format-specific constraint (single-file requires depth <= 3) is enforced in Program.RunToolLogic after all arguments are known. Changes: - Context.cs: widen GetRequiredIntArgument max from 3 to 6; remove the cross-argument SingleFile+depth>3 check from the --format case - EmitConfig.cs: widen HeadingDepth validation to 1-6; update XML doc - Program.cs RunToolLogic: add cross-argument rejection of single-file + depth > 3 with a clear WriteError diagnostic - ContextTests.cs: replace Depth4_Throws test with Depth4_SetsHeadingDepth; remove DepthAbove3AndSingleFileFormat_Throws (moved to program layer) - EmitConfigTests.cs: update AboveMaximum test to use depth 7; add ValueFour_SetsCorrectly confirming the widened range - ProgramTests.cs: add test verifying single-file + depth 4 returns non-zero exit code with --depth in the error message - docs/design and docs/verification: update --depth range to 1-6 in Context and EmitConfig docs; document new program-layer constraint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-core/emit-config.md | 14 ++-- docs/design/api-mark-tool/cli/context.md | 15 ++-- docs/design/api-mark-tool/program.md | 7 ++ .../verification/api-mark-core/emit-config.md | 17 ++-- .../verification/api-mark-tool/cli/context.md | 18 +++-- docs/verification/api-mark-tool/program.md | 16 ++++ src/ApiMark.Core/EmitConfig.cs | 16 ++-- src/ApiMark.Tool/Cli/Context.cs | 13 ++-- src/ApiMark.Tool/Program.cs | 13 ++++ test/ApiMark.Core.Tests/EmitConfigTests.cs | 24 ++++-- test/ApiMark.Tool.Tests/Cli/ContextTests.cs | 38 ++++----- test/ApiMark.Tool.Tests/ProgramTests.cs | 78 +++++++++++++++---- 12 files changed, 189 insertions(+), 80 deletions(-) diff --git a/docs/design/api-mark-core/emit-config.md b/docs/design/api-mark-core/emit-config.md index df954d6..d8457be 100644 --- a/docs/design/api-mark-core/emit-config.md +++ b/docs/design/api-mark-core/emit-config.md @@ -27,10 +27,14 @@ the emitted Markdown. Default: `OutputFormat.GradualDisclosure`. Values: - `SingleFile` — all content written into a single `api.md` file using offset heading levels. -**EmitConfig.HeadingDepth** defaults to `1` and is validated in the range 1–3 at -init time. Valid range: 1–3. The effective member heading level is `HeadingDepth + 3`; -at depth 3 members are at H6, which is the maximum heading level supported by Markdown. -Values outside 1–3 throw `ArgumentOutOfRangeException`. Ignored by `GradualDisclosure` +**EmitConfig.HeadingDepth** defaults to `1` and is validated in the range 1–6 at +init time. Valid range: 1–6. The maximum value allowed by `EmitConfig` itself is 6, +matching the highest valid ATX heading level in Markdown. Callers that use emitters +which nest additional heading levels below the top-level heading are responsible for +further restricting the value before constructing `EmitConfig` (for example, +single-file emitters render member headings at `HeadingDepth + 3`; callers must +restrict the supplied value to 1–3 to keep member headings within H1–H6). +Values outside 1–6 throw `ArgumentOutOfRangeException`. Ignored by `GradualDisclosure` emitters. **OutputFormat**: `enum` — discriminates between the two supported output @@ -49,7 +53,7 @@ do not affect equality semantics. `EmitConfig` enforces the `HeadingDepth` range at init time. Setting `HeadingDepth` to a value less than 1 throws `ArgumentOutOfRangeException` (via -`ArgumentOutOfRangeException.ThrowIfLessThan`). Setting it greater than 3 throws +`ArgumentOutOfRangeException.ThrowIfLessThan`). Setting it greater than 6 throws `ArgumentOutOfRangeException` (via `ArgumentOutOfRangeException.ThrowIfGreaterThan`). `Format` accepts any `OutputFormat` enum value and performs no range check. diff --git a/docs/design/api-mark-tool/cli/context.md b/docs/design/api-mark-tool/cli/context.md index 79c1447..7511e91 100644 --- a/docs/design/api-mark-tool/cli/context.md +++ b/docs/design/api-mark-tool/cli/context.md @@ -72,14 +72,13 @@ construction path. - *Exceptions*: `ArgumentException` on unknown flag or missing required value; `InvalidOperationException` if the log file cannot be opened. -**`--depth` range and format constraints**: `--depth` accepts integer values -in the range 1–6 for the `gradual` output format. For the `single-file` output -format, `--depth` is additionally restricted to 1–3 because member-level headings -in single-file output are rendered at `depth+3`; a depth value greater than 3 -would produce heading levels beyond H6, which is the maximum valid ATX heading -level in Markdown and cannot be rendered meaningfully by Markdown processors. -The constraint is enforced during argument parsing regardless of which order -`--depth` and `--format single-file` appear in the argument list. +**`--depth` range and validation**: `--depth` accepts integer values in the range 1–6. +Values outside 1–6 or non-integer values throw `ArgumentException` during parsing. +Context validates only that the value is an integer in the range 1–6 (valid ATX heading +levels in Markdown). It does not enforce format-specific constraints such as the single-file +depth limit (depth ≤ 3); those are cross-argument constraints discoverable only after both +`--depth` and `--format` are known, and are therefore enforced by the program layer in +`Program.RunToolLogic` after all arguments have been parsed. **Context.WriteLine(string message)** — Writes a line to stdout and to the log file. diff --git a/docs/design/api-mark-tool/program.md b/docs/design/api-mark-tool/program.md index e81a6f2..04a78e6 100644 --- a/docs/design/api-mark-tool/program.md +++ b/docs/design/api-mark-tool/program.md @@ -62,6 +62,13 @@ the generator, and calls `Parse` then `Emit`. `XmlDoc` for dotnet, `Includes` for cpp, and at least one non-exclusion `--source` pattern (i.e. a pattern not prefixed with `!`) for vhdl; calls `context.WriteError` and `PrintHelp` if any are missing. +- Enforces the single-file format depth constraint: if `Format` is `SingleFile` and + `HeadingDepth` is greater than 3, calls `context.WriteError` with a diagnostic naming + `--depth` and exits without constructing the generator. This check is placed here rather + than in `Context` because it is a cross-argument constraint (requiring knowledge of both + `--format` and `--depth`) that can only be evaluated after the full argument list is parsed. + The single-file emitters render member headings at `depth+3`; a depth above 3 would + produce H7+ headings unsupported by CommonMark. - Calls `CreateGenerator(context)`, then `generator.Parse(context)` to get an `IApiEmitter`, then `emitter.Emit(factory, emitConfig, context)` where `emitConfig` is constructed from `context.Format` and `context.HeadingDepth`. diff --git a/docs/verification/api-mark-core/emit-config.md b/docs/verification/api-mark-core/emit-config.md index c995100..f992f81 100644 --- a/docs/verification/api-mark-core/emit-config.md +++ b/docs/verification/api-mark-core/emit-config.md @@ -22,9 +22,9 @@ verification. Property-default compliance is enforced by the test assertions. - `EmitConfig.HeadingDepth` defaults to `1` when no explicit value is supplied. - Both properties can be overridden at object-initialization time using `init` setters. - Setting `HeadingDepth` to a value less than 1 throws `ArgumentOutOfRangeException`. -- Setting `HeadingDepth` to a value greater than 3 throws `ArgumentOutOfRangeException`. -- Setting `HeadingDepth` to a valid non-default value (e.g., `2`) is accepted and - reflected by the property. +- Setting `HeadingDepth` to a value greater than 6 throws `ArgumentOutOfRangeException`. +- Setting `HeadingDepth` to a valid non-default value within 1–6 (e.g., `2` or `4`) is + accepted and reflected by the property. ### Test Scenarios @@ -49,11 +49,16 @@ confirming that values below 1 are rejected at init time. This scenario is teste `EmitConfig_HeadingDepth_BelowMinimum_ThrowsArgumentOutOfRangeException`. **HeadingDepth above maximum throws ArgumentOutOfRangeException**: Verifies that -constructing `new EmitConfig { HeadingDepth = 4 }` throws `ArgumentOutOfRangeException`, -confirming that values above 3 are rejected (because depth 4 would produce H7 member -headings, which are unsupported by CommonMark). This scenario is tested by +constructing `new EmitConfig { HeadingDepth = 7 }` throws `ArgumentOutOfRangeException`, +confirming that values above 6 are rejected (because H7 is not a valid ATX heading level +in CommonMark). This scenario is tested by `EmitConfig_HeadingDepth_AboveMaximum_ThrowsArgumentOutOfRangeException`. +**HeadingDepth value 4 is accepted**: Verifies that `new EmitConfig { HeadingDepth = 4 }` +succeeds and `HeadingDepth` reflects the supplied value, confirming that depth 4 is within +the widened 1–6 valid range. This scenario is tested by +`EmitConfig_HeadingDepth_ValueFour_SetsCorrectly`. + **HeadingDepth valid non-default value is accepted**: Verifies that `new EmitConfig { HeadingDepth = 2 }` succeeds and `HeadingDepth` reflects the supplied value, confirming the full selectable range. This scenario is tested by diff --git a/docs/verification/api-mark-tool/cli/context.md b/docs/verification/api-mark-tool/cli/context.md index 0eb44c6..16aa7c1 100644 --- a/docs/verification/api-mark-tool/cli/context.md +++ b/docs/verification/api-mark-tool/cli/context.md @@ -23,8 +23,9 @@ up after itself. No other external files, services, or configuration are require - `--log ` creates the file and captures `WriteLine` output. - `--log ` also captures `WriteError` output. - `--depth ` sets `HeadingDepth` to `n`; values outside 1–6 or non-integers throw `ArgumentException`. -- When `--format single-file` is in effect, `--depth` values above 3 throw `ArgumentException` regardless of argument order. -- `--depth 3` with `--format single-file` is accepted (boundary value). +- `--depth 4` with no `--format` flag is accepted (`HeadingDepth = 4`). +- `--depth 6` is accepted as the upper boundary of the valid ATX heading range (`HeadingDepth = 6`). +- Context validates only that the value is an integer in the range 1–6 (valid ATX heading levels). It does not enforce format-specific constraints such as the single-file depth limit; those are cross-argument constraints enforced by the program layer after all arguments are parsed. - `--results`/`--result` sets `ResultsFile` to the supplied path. - Flag tokens (starting with `-`) supplied as values for string-valued options are rejected with `ArgumentException`. - `--includes` accepts one directory path per flag; repeated flags accumulate paths into `Includes`. @@ -79,6 +80,12 @@ up after itself. No other external files, services, or configuration are require **`Context_Create_WithDepthOptionOutOfRange_ThrowsArgumentException`**: `--depth 0`, `--depth 7`, and `--depth abc` each throw `ArgumentException` (theory test covering all three variants). +**`Context_Create_WithDepth4_SetsHeadingDepth`**: `--depth 4` → `HeadingDepth = 4` (depth 4 is +within the valid 1–6 range; format-specific constraints are enforced downstream). + +**`Context_Create_WithDepth6_SetsHeadingDepth`**: `--depth 6` → `HeadingDepth = 6` (depth 6 is +the upper boundary of the valid ATX heading range and must be accepted without error). + **`Context_Create_WithResultsOption_SetsResultsFile`**: `--results results.trx` and `--result results.trx` both set `ResultsFile = "results.trx"` (theory test covering both variants). @@ -152,13 +159,10 @@ corresponding properties set simultaneously. **`Context_OpenLogFile_ErrorOutputAlsoWrittenToLog`**: `--log ` + `WriteError` → file exists and contains the error message after `Dispose`. -**`Context_Create_WithDepthAbove3AndSingleFileFormat_ThrowsArgumentException`**: -`--format single-file --depth 4` and `--depth 4 --format single-file` both throw -`ArgumentException` (theory test covering both argument orderings). - **`Context_Create_WithDepth3AndSingleFileFormat_Succeeds`**: `--format single-file --depth 3` parses successfully with `HeadingDepth = 3` and `Format = OutputFormat.SingleFile` -(boundary value is accepted). +(boundary value is accepted; whether depth 3 is also valid for a particular emitter is +enforced by the emitter layer, not by Context). **`Context_Create_WithFlagValueForOutput_ThrowsArgumentException`**: `["dotnet", "--output", "--silent"]` — a flag token supplied as the `--output` value — throws `ArgumentException`, confirming diff --git a/docs/verification/api-mark-tool/program.md b/docs/verification/api-mark-tool/program.md index 3687b80..30e5722 100644 --- a/docs/verification/api-mark-tool/program.md +++ b/docs/verification/api-mark-tool/program.md @@ -27,6 +27,10 @@ output directory. No external service, privileged configuration, or network acce - `--validate` runs self-validation tests and returns exit code zero when all pass. - `--validate --results ` additionally writes the results to the specified file path. - The `vhdl` subcommand rejects invocations without at least one non-exclusion `--source` pattern. +- `--format single-file --depth 4` returns a non-zero exit code with a diagnostic naming `--depth` + because member headings in single-file output are at `depth+3` and depth 4 would produce H7. +- `--format gradual --depth 4` is accepted and returns exit code zero (the single-file depth + constraint does not apply to the gradual-disclosure format). ### Test Scenarios @@ -94,3 +98,15 @@ subcommand without any `--source` arguments returns a non-zero exit code and a d message, confirming that the subcommand enforces the requirement for at least one non-exclusion source pattern before attempting generation. This scenario is tested by `Program_Main_WithVhdlSubcommand_MissingSourceFiles_ReturnsNonZeroExitCode`. + +**Single-file format with depth 4 returns non-zero exit code**: Verifies that supplying +`--format single-file --depth 4` produces a non-zero exit code and a diagnostic naming +`--depth`, confirming that the cross-argument depth constraint is enforced in `RunToolLogic` +after both flags are known. The constraint exists because single-file emitters render member +headings at `depth+3`; depth 4 would produce H7+, which CommonMark does not support. This +scenario is tested by `Program_Main_WithSingleFileFormatAndDepth4_ReturnsNonZeroExitCode`. + +**Gradual-disclosure format with depth 4 exits zero**: Verifies that supplying +`--format gradual --depth 4` with a valid dotnet subcommand exits with code zero, confirming +that the single-file depth constraint is not applied to the gradual-disclosure format. This +scenario is tested by `Program_Main_WithGradualFormatAndDepth4_ExitsZero`. diff --git a/src/ApiMark.Core/EmitConfig.cs b/src/ApiMark.Core/EmitConfig.cs index dc1f2e0..258fc4f 100644 --- a/src/ApiMark.Core/EmitConfig.cs +++ b/src/ApiMark.Core/EmitConfig.cs @@ -23,22 +23,24 @@ public sealed class EmitConfig /// output. A value of 1 (the default) /// produces an H1 top-level heading; a value of 2 produces H2, and so on. /// Only used when is . - /// Valid range: 1–3. At depth 3, member headings reach H6 (the maximum in - /// Markdown); values above 3 would produce H7+ headings which are not defined - /// by CommonMark. + /// Valid range: 1–6. The caller is responsible for ensuring that the chosen + /// depth is compatible with the number of heading levels the emitter will + /// nest below the top-level heading (e.g. single-file emitters that add up + /// to three additional levels must restrict the caller-supplied value to 1–3 + /// to stay within the H1–H6 range supported by CommonMark). /// /// - /// Thrown when the supplied value is less than 1 or greater than 3. + /// Thrown when the supplied value is less than 1 or greater than 6. /// public int HeadingDepth { get => _headingDepth; init { - // Enforce the 1–3 range so that the effective member heading level - // (HeadingDepth + 3) stays within the H1–H6 range supported by Markdown + // Enforce the 1–6 range: values outside this range cannot produce valid + // ATX Markdown headings (H1–H6), regardless of downstream nesting depth ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); - ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 3); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 6); _headingDepth = value; } } diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index 8886545..693c5a8 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -50,7 +50,7 @@ internal sealed class Context : IContext, IDisposable public OutputFormat Format { get; private init; } = OutputFormat.GradualDisclosure; /// - /// Gets the heading depth for markdown output (default is 1). + /// Gets the heading depth for markdown output (default is 1, valid range 1–6). /// public int HeadingDepth { get; private init; } = 1; @@ -468,7 +468,10 @@ private int ParseArgument(string arg, string[] args, int index) return index + 1; case "--depth": - HeadingDepth = GetRequiredIntArgument(arg, args, index, "a heading depth argument", 1, 3); + // Context validates the first-principles range (1–6: valid ATX heading levels in Markdown). + // Format-specific constraints (e.g. single-file requires depth ≤ 3 to keep member headings + // at H6 or above) are enforced by the program layer after all arguments are known, not here. + HeadingDepth = GetRequiredIntArgument(arg, args, index, "a heading depth argument", 1, 6); return index + 1; case "--format": @@ -482,12 +485,6 @@ private int ParseArgument(string arg, string[] args, int index) $"'{arg}' value must be 'gradual' or 'single-file', got '{formatValue}'.", nameof(args)), }; - if (Format == OutputFormat.SingleFile && HeadingDepth > 3) - { - throw new ArgumentException( - $"'--depth' value must be 1–3 when '--format single-file' is used (member headings are at depth+3); got {HeadingDepth}.", - nameof(args)); - } return index + 1; } diff --git a/src/ApiMark.Tool/Program.cs b/src/ApiMark.Tool/Program.cs index 4956afa..dac8f87 100644 --- a/src/ApiMark.Tool/Program.cs +++ b/src/ApiMark.Tool/Program.cs @@ -184,6 +184,19 @@ private static void RunToolLogic(Context context) return; } + // Enforce the single-file format depth constraint: the single-file emitter writes member + // headings at depth+3 (assembly at depth, namespace at depth+1, type at depth+2, member + // at depth+3), so depth > 3 would produce H7+ headings which CommonMark does not support. + // This check lives here (not in Context) because it is a format-specific, cross-argument + // constraint discoverable only after both --format and --depth are known. + if (context.Format == OutputFormat.SingleFile && context.HeadingDepth > 3) + { + context.WriteError( + $"--depth must be 1-3 for single-file output " + + $"(member headings would exceed H6 at depth {context.HeadingDepth})."); + return; + } + try { // Construct the generator, parse symbols, then emit using the configured format diff --git a/test/ApiMark.Core.Tests/EmitConfigTests.cs b/test/ApiMark.Core.Tests/EmitConfigTests.cs index c6ed0e9..69906d6 100644 --- a/test/ApiMark.Core.Tests/EmitConfigTests.cs +++ b/test/ApiMark.Core.Tests/EmitConfigTests.cs @@ -22,15 +22,29 @@ public void EmitConfig_HeadingDepth_BelowMinimum_ThrowsArgumentOutOfRangeExcepti } /// - /// Verifies that setting to 4 throws - /// because 4 is above the maximum valid - /// depth of 3 (which would produce an H7 member heading, unsupported by Markdown). + /// Verifies that setting to 7 throws + /// because 7 is above the maximum valid + /// depth of 6 (the highest ATX heading level supported by Markdown). /// [Fact] public void EmitConfig_HeadingDepth_AboveMaximum_ThrowsArgumentOutOfRangeException() { - // Arrange / Act / Assert: depth 4 is above the valid range and must be rejected - Assert.Throws(() => new EmitConfig { HeadingDepth = 4 }); + // Arrange / Act / Assert: depth 7 is above the valid range and must be rejected + Assert.Throws(() => new EmitConfig { HeadingDepth = 7 }); + } + + /// + /// Verifies that setting to 4 succeeds + /// because 4 is within the widened valid range of 1–6. + /// + [Fact] + public void EmitConfig_HeadingDepth_ValueFour_SetsCorrectly() + { + // Arrange / Act: construct EmitConfig with a heading depth of 4 — valid in the 1–6 range + var config = new EmitConfig { HeadingDepth = 4 }; + + // Assert: the property must reflect the supplied value + Assert.Equal(4, config.HeadingDepth); } /// diff --git a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs index cfacbab..8045c83 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -722,35 +722,37 @@ public void Context_OpenLogFile_ErrorOutputAlsoWrittenToLog() } /// - /// Validates that --depth 4 without any format flag throws - /// because EmitConfig.HeadingDepth accepts a maximum of 3 for all output formats. + /// Validates that --depth 4 is accepted by because + /// the valid range is 1–6; format-specific constraints are enforced downstream. /// [Fact] - public void Context_Create_WithDepth4_ThrowsArgumentException() + public void Context_Create_WithDepth4_SetsHeadingDepth() { - // Arrange: supply --depth 4 with no format flag — gradual-disclosure is the default format - // and must also reject depth > 3 (member headings at depth+3 would exceed H6) + // Arrange: supply --depth 4 — this is within the 1–6 range accepted by Context var args = new[] { "--depth", "4" }; - // Act / Assert: depth > 3 must throw ArgumentException for any format - Assert.Throws(() => Context.Create(args)); + // Act + using var context = Context.Create(args); + + // Assert: depth 4 is valid in Context and must be stored without error + Assert.Equal(4, context.HeadingDepth); } /// - /// Validates that --depth values above 3 throw - /// when --format single-file is specified, regardless of argument order. + /// Validates that --depth 6 is accepted by because 6 + /// is the upper bound of valid ATX heading levels in Markdown (H1–H6). /// - [Theory] - [InlineData("--format", "single-file", "--depth", "4")] - [InlineData("--depth", "4", "--format", "single-file")] - public void Context_Create_WithDepthAbove3AndSingleFileFormat_ThrowsArgumentException( - string arg1, string val1, string arg2, string val2) + [Fact] + public void Context_Create_WithDepth6_SetsHeadingDepth() { - // Arrange: build the argument array from the theory parameters - var args = new[] { arg1, val1, arg2, val2 }; + // Arrange: supply --depth 6 — the maximum value in the 1–6 first-principles range + var args = new[] { "--depth", "6" }; - // Act / Assert: depth > 3 with single-file format must throw ArgumentException - Assert.Throws(() => Context.Create(args)); + // Act + using var context = Context.Create(args); + + // Assert: depth 6 is the upper boundary and must be stored without error + Assert.Equal(6, context.HeadingDepth); } /// diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index 295aa7e..cf94a9c 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -441,37 +441,83 @@ public void Program_Main_WithVhdlSubcommand_MissingSourceFiles_ReturnsNonZeroExi } /// - /// Validates that --validate --results <file> writes a results file to - /// the specified path and exits with code 0. + /// Validates that --format single-file --depth 4 exits with a non-zero code + /// and writes a diagnostic naming both flags, because member headings in single-file + /// output are at depth+3 and a depth of 4 would produce H7, which exceeds H6. /// [Fact] - public void Program_Main_WithValidateAndResultsFile_WritesResultsFile() + public void Program_Main_WithSingleFileFormatAndDepth4_ReturnsNonZeroExitCode() { - // Arrange: create a temporary results file path that does not yet exist - var resultsFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".trx"); - var originalOut = Console.Out; - using var outWriter = new StringWriter(); + // Arrange: use the fixture assembly so the tool reaches the cross-argument validation + // step; the error must be caught before the generator is constructed + var assemblyPath = typeof(SampleClass).Assembly.Location; + var xmlDocPath = Path.ChangeExtension(assemblyPath, ".xml"); + var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); try { - Console.SetOut(outWriter); + Console.SetError(errorWriter); // Act - var exitCode = Program.Main(["--validate", "--results", resultsFile]); + var exitCode = Program.Main([ + "dotnet", + "--assembly", assemblyPath, + "--xml-doc", xmlDocPath, + "--output", outputDir, + "--format", "single-file", + "--depth", "4", + ]); - // Assert: self-validation succeeds and the results file is written to the requested path - Assert.Equal(0, exitCode); - Assert.True(File.Exists(resultsFile), "Expected results file to be created"); + // Assert: the cross-argument constraint must produce a non-zero exit and a + // diagnostic that names --depth so the user can identify and correct the error + Assert.NotEqual(0, exitCode); + Assert.Contains("--depth", errorWriter.ToString(), StringComparison.Ordinal); } finally { - // Restore console and remove the temporary results file regardless of outcome - Console.SetOut(originalOut); + // Restore the original error stream regardless of outcome + Console.SetError(originalError); + } + } - if (File.Exists(resultsFile)) + /// + /// Validates that --format gradual --depth 4 exits with code 0 because the + /// single-file depth constraint does not apply to the gradual-disclosure format. + /// + [Fact] + public void Program_Main_WithGradualFormatAndDepth4_ExitsZero() + { + // Arrange: use the fixture assembly with gradual format and depth 4 — this combination + // is valid because gradual-disclosure emitters do not nest headings beyond depth+1 + var assemblyPath = typeof(SampleClass).Assembly.Location; + var xmlDocPath = Path.ChangeExtension(assemblyPath, ".xml"); + var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + + try + { + // Act + var exitCode = Program.Main([ + "dotnet", + "--assembly", assemblyPath, + "--xml-doc", xmlDocPath, + "--output", outputDir, + "--format", "gradual", + "--depth", "4", + ]); + + // Assert: gradual format with depth 4 is valid and must exit zero + Assert.Equal(0, exitCode); + } + finally + { + // Clean up the temporary output directory regardless of outcome + if (Directory.Exists(outputDir)) { - File.Delete(resultsFile); + Directory.Delete(outputDir, recursive: true); } } } } + From 6d62d2ab8dc35d690e5428475c9aee1defbd1294 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 07:57:50 -0400 Subject: [PATCH 58/62] refactor: move single-file depth constraint to program layer, Context validates 1-6 Context now validates --depth against first principles (1-6: valid ATX heading levels in Markdown). The single-file format constraint (depth <= 3) is enforced post-parse in Program.cs where both --format and --depth are known. This separation ensures Context does not memorize downstream format constraints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/verification/api-mark-tool/cli/context.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/verification/api-mark-tool/cli/context.md b/docs/verification/api-mark-tool/cli/context.md index 16aa7c1..c184b18 100644 --- a/docs/verification/api-mark-tool/cli/context.md +++ b/docs/verification/api-mark-tool/cli/context.md @@ -86,6 +86,9 @@ within the valid 1–6 range; format-specific constraints are enforced downstrea **`Context_Create_WithDepth6_SetsHeadingDepth`**: `--depth 6` → `HeadingDepth = 6` (depth 6 is the upper boundary of the valid ATX heading range and must be accepted without error). +**`Context_Create_WithDepth6_SetsHeadingDepth`**: `--depth 6` → `HeadingDepth = 6` (depth 6 is +the upper boundary of the valid ATX heading range and must be accepted without error). + **`Context_Create_WithResultsOption_SetsResultsFile`**: `--results results.trx` and `--result results.trx` both set `ResultsFile = "results.trx"` (theory test covering both variants). From 69ed2314a7a3974c5d32bab86142ebedcfcb2135 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 08:37:11 -0400 Subject: [PATCH 59/62] fix: move single-file depth requirement to program layer in reqstream The ValidateDepthForSingleFileFormat requirement belongs to Program (post-parse cross-argument validation), not Context. Context_Create_ WithDepth3AndSingleFileFormat_Succeeds moved to ParseDepthOption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/api-mark-tool/cli.yaml | 1 - docs/reqstream/api-mark-tool/cli/context.yaml | 11 +---------- docs/reqstream/api-mark-tool/program.yaml | 1 + 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/docs/reqstream/api-mark-tool/cli.yaml b/docs/reqstream/api-mark-tool/cli.yaml index b849efe..7602c4c 100644 --- a/docs/reqstream/api-mark-tool/cli.yaml +++ b/docs/reqstream/api-mark-tool/cli.yaml @@ -25,7 +25,6 @@ sections: - ApiMarkTool-Cli-Context-ParseFormatOption - ApiMarkTool-Cli-Context-ParseClangPath - ApiMarkTool-Cli-Context-ParseVhdlSourceOptions - - ApiMarkTool-Cli-Context-ValidateDepthForSingleFileFormat - ApiMarkTool-Cli-SuppressOutputWhenSilent - ApiMarkTool-Cli-RouteOutputThroughContext tests: [Context_Cli_ParsesAllGlobalFlags] diff --git a/docs/reqstream/api-mark-tool/cli/context.yaml b/docs/reqstream/api-mark-tool/cli/context.yaml index 373b5a4..a1757f5 100644 --- a/docs/reqstream/api-mark-tool/cli/context.yaml +++ b/docs/reqstream/api-mark-tool/cli/context.yaml @@ -128,6 +128,7 @@ sections: matches the valid ATX heading levels in Markdown. tests: - Context_Create_WithDepthOption_SetsHeadingDepth + - Context_Create_WithDepth3AndSingleFileFormat_Succeeds - Context_Create_WithDepthOptionOutOfRange_ThrowsArgumentException - id: ApiMarkTool-Cli-Context-ParseResultsOption title: Context shall parse the --results (alias --result) option and @@ -186,16 +187,6 @@ sections: - Context_Create_WithSourceOption_SetsSources - Context_Create_WithRepeatedSource_AccumulatesAllPaths - Context_Create_WithSourceExclusionPattern_ForwardsVerbatim - - id: ApiMarkTool-Cli-Context-ValidateDepthForSingleFileFormat - title: Context shall reject --depth values greater than 3 when --format - single-file is used. - justification: | - In single-file format, member-level headings are rendered at depth+3; a depth - greater than 3 would produce heading levels beyond H6, which is the maximum - valid Markdown heading level and cannot be rendered meaningfully. - tests: - - Context_Create_WithDepthAbove3AndSingleFileFormat_ThrowsArgumentException - - Context_Create_WithDepth3AndSingleFileFormat_Succeeds - id: ApiMarkTool-Cli-Context-RejectFlagAsOptionValue title: Context shall throw ArgumentException when a flag token (starting with '-') is supplied as the value of a string-valued option. diff --git a/docs/reqstream/api-mark-tool/program.yaml b/docs/reqstream/api-mark-tool/program.yaml index 7249976..6a1a062 100644 --- a/docs/reqstream/api-mark-tool/program.yaml +++ b/docs/reqstream/api-mark-tool/program.yaml @@ -100,3 +100,4 @@ sections: tests: - Program_Main_WithMissingAssembly_PrintsErrorAndFails - Program_Main_WithInvalidVisibility_ReturnsNonZeroExitCode + - Program_Main_WithSingleFileFormatAndDepth4_ReturnsNonZeroExitCode From bdb89aaa7cd9ecee8e6f61aef3d3bf3bc77e4b81 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 08:49:45 -0400 Subject: [PATCH 60/62] fix: address PR review round 3 findings - Guard Directory.Exists before Directory.GetFiles in ResolveOnDiskPath - Update EmitConfig-ValidateHeadingDepthRange requirement to 1-6 range - Remove duplicate Depth6 scenario in context verification doc - Restore exclusion-pattern and non-existent-root coverage in file-system-globbing verification doc and reqstream requirements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/api-mark-core/emit-config.yaml | 12 ++++++------ docs/reqstream/ots/file-system-globbing.yaml | 18 ++++++++++++++++++ docs/verification/api-mark-tool/cli/context.md | 3 --- docs/verification/ots/file-system-globbing.md | 2 +- src/ApiMark.Core/GlobFileCollector.cs | 5 +++++ 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/reqstream/api-mark-core/emit-config.yaml b/docs/reqstream/api-mark-core/emit-config.yaml index 536bff3..768a38e 100644 --- a/docs/reqstream/api-mark-core/emit-config.yaml +++ b/docs/reqstream/api-mark-core/emit-config.yaml @@ -31,14 +31,14 @@ sections: - EmitConfig_DefaultHeadingDepth_IsOne - IApiEmitter_Emit_SingleFile_ProducesSingleApiMd - id: ApiMarkCore-EmitConfig-ValidateHeadingDepthRange - title: EmitConfig shall reject HeadingDepth values outside the range 1–3 + title: EmitConfig shall reject HeadingDepth values outside the range 1–6 by throwing ArgumentOutOfRangeException at init time. justification: | - The effective member heading level is HeadingDepth + 3. A depth of 3 - produces H6 member headings, which is the maximum in CommonMark. Values - above 3 would generate H7+ headings that are not rendered as headings by - standard processors. Enforcing the range at init time produces a clear - error at the point of misconfiguration. + Valid ATX Markdown heading levels are H1–H6; values outside 1–6 cannot + produce valid headings regardless of nesting depth. Format-specific + constraints (e.g. single-file requires depth ≤ 3 so member headings stay + at H6 or above) are the caller's responsibility. Enforcing the 1–6 range + at init time produces a clear error at the point of misconfiguration. tests: - EmitConfig_HeadingDepth_BelowMinimum_ThrowsArgumentOutOfRangeException - EmitConfig_HeadingDepth_AboveMaximum_ThrowsArgumentOutOfRangeException diff --git a/docs/reqstream/ots/file-system-globbing.yaml b/docs/reqstream/ots/file-system-globbing.yaml index a9e8ab1..e5a24a2 100644 --- a/docs/reqstream/ots/file-system-globbing.yaml +++ b/docs/reqstream/ots/file-system-globbing.yaml @@ -15,3 +15,21 @@ sections: tests: - GlobFileCollector_Collect_RelativeVhdPattern_FindsVhdFiles - GlobFileCollector_Collect_AbsolutePattern_FindsFiles + - id: FileSystemGlobbing-Matcher-ApplyExclusionPatterns + title: Microsoft.Extensions.FileSystemGlobbing shall exclude files matching + gitignore-style exclusion patterns prefixed with '!'. + justification: | + GlobFileCollector passes '!'-prefixed patterns to Matcher as exclusion + rules. Any defect in exclusion evaluation would cause excluded files to + appear in language-generator input. + tests: + - GlobFileCollector_Collect_ExclusionPattern_RemovesMatchedFiles + - id: FileSystemGlobbing-Matcher-HandleNonExistentRoot + title: Microsoft.Extensions.FileSystemGlobbing shall return an empty result + when the root directory does not exist, without throwing. + justification: | + GlobFileCollector passes a working directory that may not exist on disk. + A silent empty result is the correct behavior; an exception would + propagate to callers as an unhandled error. + tests: + - GlobFileCollector_Collect_NonExistentRoot_ReturnsEmptyWithoutThrowing diff --git a/docs/verification/api-mark-tool/cli/context.md b/docs/verification/api-mark-tool/cli/context.md index c184b18..16aa7c1 100644 --- a/docs/verification/api-mark-tool/cli/context.md +++ b/docs/verification/api-mark-tool/cli/context.md @@ -86,9 +86,6 @@ within the valid 1–6 range; format-specific constraints are enforced downstrea **`Context_Create_WithDepth6_SetsHeadingDepth`**: `--depth 6` → `HeadingDepth = 6` (depth 6 is the upper boundary of the valid ATX heading range and must be accepted without error). -**`Context_Create_WithDepth6_SetsHeadingDepth`**: `--depth 6` → `HeadingDepth = 6` (depth 6 is -the upper boundary of the valid ATX heading range and must be accepted without error). - **`Context_Create_WithResultsOption_SetsResultsFile`**: `--results results.trx` and `--result results.trx` both set `ResultsFile = "results.trx"` (theory test covering both variants). diff --git a/docs/verification/ots/file-system-globbing.md b/docs/verification/ots/file-system-globbing.md index 2e105c2..cc6b48d 100644 --- a/docs/verification/ots/file-system-globbing.md +++ b/docs/verification/ots/file-system-globbing.md @@ -6,7 +6,7 @@ `test/ApiMark.Core.Tests/GlobFileCollectorTests.cs` that exercise `GlobFileCollector`, the sole consumer of the library. The tests operate against temporary real directories on disk, confirming that the `Matcher` API behaves as expected for the subset of -features ApiMark uses: include patterns rooted at a given directory. +features ApiMark uses: include patterns, exclusion patterns, and non-existent roots. ### Test Scenarios diff --git a/src/ApiMark.Core/GlobFileCollector.cs b/src/ApiMark.Core/GlobFileCollector.cs index 5dadd73..ce554be 100644 --- a/src/ApiMark.Core/GlobFileCollector.cs +++ b/src/ApiMark.Core/GlobFileCollector.cs @@ -193,6 +193,11 @@ private static void AccumulateResults(HashSet collected, IEnumerable 0 ? matches[0] : null; } From 4f3279aeefd19af6baacbad914b480436bb9fd1b Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 09:10:35 -0400 Subject: [PATCH 61/62] fix: point SupportValidateOption requirement at existing test Validation_Run_WithResultsTrxFile_CreatesTrxFile already proves the program can write results to a file; no new Program_Main_* test needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/api-mark-tool/program.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reqstream/api-mark-tool/program.yaml b/docs/reqstream/api-mark-tool/program.yaml index 6a1a062..076e1e8 100644 --- a/docs/reqstream/api-mark-tool/program.yaml +++ b/docs/reqstream/api-mark-tool/program.yaml @@ -81,7 +81,7 @@ sections: requiring external test infrastructure or the full source tree. tests: - Program_Main_WithValidateFlag_ExitsZero - - Program_Main_WithValidateAndResultsFile_WritesResultsFile + - Validation_Run_WithResultsTrxFile_CreatesTrxFile - id: ApiMarkTool-Program-SupportVersionOption title: Program shall support a global --version option. justification: | From c96e5e27c3f30801e2e80862d4fef58a9333a982 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 26 Jun 2026 09:33:50 -0400 Subject: [PATCH 62/62] fix: add literate comment for Ordinal invariant, fix line endings, escape angle brackets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GlobFileCollector: add inline block comment explaining why StringComparer.Ordinal is correct — all paths in collected carry on-disk casing via Matcher or ResolveOnDiskPath, so Ordinal deduplication is exact on all filesystems - VhdlAstParser: use ReplaceLineEndings to handle CR-only line endings in addition to CRLF and LF - XmlDocReader: escape angle brackets in FormatTypeArity output as \< and \> so generic type params are not parsed as HTML tags in Markdown prose Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Core/GlobFileCollector.cs | 7 +++++++ src/ApiMark.DotNet/XmlDocReader.cs | 4 +++- src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs | 5 ++++- test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs | 4 ++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/ApiMark.Core/GlobFileCollector.cs b/src/ApiMark.Core/GlobFileCollector.cs index ce554be..de7d880 100644 --- a/src/ApiMark.Core/GlobFileCollector.cs +++ b/src/ApiMark.Core/GlobFileCollector.cs @@ -80,6 +80,13 @@ public static IReadOnlyList Collect( ArgumentNullException.ThrowIfNull(workingDirectory); var extensions = new HashSet(languageExtensions, StringComparer.OrdinalIgnoreCase); + + // Ordinal comparison is correct here because every path added to `collected` carries + // on-disk casing: glob paths come from Matcher.GetResultsInFullPath (which returns the + // real filesystem entry name) and literal paths go through ResolveOnDiskPath (which uses + // Directory.GetFiles to obtain the same on-disk name). Two patterns that refer to the + // same physical file will therefore produce identical strings, so Ordinal deduplication + // is exact on both case-sensitive (Linux) and case-insensitive (Windows/macOS) filesystems. var collected = new HashSet(StringComparer.Ordinal); foreach (var pattern in patterns) diff --git a/src/ApiMark.DotNet/XmlDocReader.cs b/src/ApiMark.DotNet/XmlDocReader.cs index f406d7d..9f81e88 100644 --- a/src/ApiMark.DotNet/XmlDocReader.cs +++ b/src/ApiMark.DotNet/XmlDocReader.cs @@ -777,7 +777,9 @@ private static string FormatTypeArity(string typeName) var typeParams = arity == 1 ? "T" : string.Join(", ", Enumerable.Range(1, arity).Select(i => $"T{i}")); - return $"{baseName}<{typeParams}>"; + // Escape angle brackets for Markdown prose — bare is parsed as an HTML tag + // by Markdown renderers and the type parameter would be stripped or hidden + return $"{baseName}\\<{typeParams}\\>"; } /// diff --git a/src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs b/src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs index 8a40f13..04e17ab 100644 --- a/src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs +++ b/src/ApiMark.Vhdl/VhdlAst/VhdlAstParser.cs @@ -17,7 +17,10 @@ internal static class VhdlAstParser internal static VhdlFileModel Parse(string filePath) { var sourceText = File.ReadAllText(filePath); - var lines = sourceText.Split('\n').Select(l => l.TrimEnd('\r')).ToArray(); + // Normalize all line-ending conventions (CRLF, LF, CR) to LF so that + // line-index lookups in VhdlVisitor are consistent regardless of the + // file's original encoding + var lines = sourceText.ReplaceLineEndings("\n").Split('\n'); var input = new AntlrInputStream(sourceText); var lexer = new vhdl2008Lexer(input); diff --git a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs index 493b2ff..a31bce6 100644 --- a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs +++ b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs @@ -1596,9 +1596,9 @@ public void XmlDocReader_GetSummary_WithSeeGenericTypeCref_FormatsWithTypeParame var reader = new XmlDocReader(path); var summary = reader.GetSummary("M:Foo.Bar.UseList"); - // Assert: arity marker is rendered as angle-bracket notation, not stripped + // Assert: arity marker is rendered as escaped angle-bracket notation for Markdown prose Assert.NotNull(summary); - Assert.Contains("List", summary, StringComparison.Ordinal); + Assert.Contains(@"List\", summary, StringComparison.Ordinal); } finally {