From 7a7e15089fc0a044a960048270820e1b070d7655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 08:38:54 +0100 Subject: [PATCH 01/25] Harden API docs layout and source-link preflight --- Docs/PowerForge.Web.ApiDocs.md | 1 + Docs/PowerForge.Web.Pipeline.md | 3 +- .../WebApiDocsGeneratorContractTests.cs | 12 +++++ .../WebApiDocsGeneratorSourceAndCssTests.cs | 53 +++++++++++++++++++ .../WebCliApiDocsPreflightTests.cs | 33 ++++++++++++ .../WebPipelineRunnerApiDocsPreflightTests.cs | 44 +++++++++++++++ .../WebPipelineRunner.Tasks.Content.cs | 47 ++++++++++++++++ .../Services/WebApiDocsGenerator.Html.cs | 53 +------------------ .../Services/WebApiDocsGenerator.cs | 13 ++++- 9 files changed, 205 insertions(+), 54 deletions(-) diff --git a/Docs/PowerForge.Web.ApiDocs.md b/Docs/PowerForge.Web.ApiDocs.md index 2a3b59e6..1a0256a2 100644 --- a/Docs/PowerForge.Web.ApiDocs.md +++ b/Docs/PowerForge.Web.ApiDocs.md @@ -352,6 +352,7 @@ Notes: - Source-link diagnostics also emit `[PFWEB.APIDOCS.SOURCE]` warnings for common misconfigurations: - `sourceUrlMappings` prefixes that never match discovered source paths - likely duplicated GitHub path prefixes (a common cause of 404 "Edit on GitHub" links) + - `sourceRoot` pointing one level above the GitHub repo while `sourceUrl` targets a single repo without `{root}` - source URL templates missing a path token (`{path}`, `{pathNoRoot}`, or `{pathNoPrefix}`) - unsupported source URL template tokens (anything outside `{path}`, `{line}`, `{root}`, `{pathNoRoot}`, `{pathNoPrefix}`) - Display + member diagnostics: diff --git a/Docs/PowerForge.Web.Pipeline.md b/Docs/PowerForge.Web.Pipeline.md index 626b0637..d231349b 100644 --- a/Docs/PowerForge.Web.Pipeline.md +++ b/Docs/PowerForge.Web.Pipeline.md @@ -286,11 +286,12 @@ Notes: - Full warning code catalog: `Docs/PowerForge.Web.WarningCodes.md`. - If `nav` is provided but your custom `headerHtml`/`footerHtml` fragments do not contain `{{NAV_LINKS}}` / `{{NAV_ACTIONS}}`, the generator emits `[PFWEB.APIDOCS.NAV]` warnings. - Source-link diagnostics emit `[PFWEB.APIDOCS.SOURCE]` warnings for mapping issues (for example unmatched `sourceUrlMappings.pathPrefix` or likely duplicated GitHub path prefixes causing 404 source/edit links). +- Preflight also warns when `sourceRoot` appears to be one level above the targeted GitHub repo and `sourceUrl` does not use `{root}`. This usually means links will render as `//...` and 404. - Source URL templates are validated preflight: - require at least one path token (`{path}`, `{pathNoRoot}`, `{pathNoPrefix}`) - warn on unsupported tokens (supported: `{path}`, `{line}`, `{root}`, `{pathNoRoot}`, `{pathNoPrefix}`) - Additional `apidocs` preflight checks emit warning codes before generation starts: - - `[PFWEB.APIDOCS.SOURCE]` for source-link config issues (for example `sourceUrlMappings` configured without `sourceRoot`/`sourceUrl`, missing `sourceRoot` directory, duplicate mapping prefixes) + - `[PFWEB.APIDOCS.SOURCE]` for source-link config issues (for example `sourceUrlMappings` configured without `sourceRoot`/`sourceUrl`, missing `sourceRoot` directory, duplicate mapping prefixes, or `sourceRoot` aimed above the repo root) - `[PFWEB.APIDOCS.NAV]` for nav config issues (for example `navSurface` configured without `nav`) - `[PFWEB.APIDOCS.POWERSHELL]` for missing PowerShell examples paths when `psExamplesPath` is set - `warningPreviewCount`: how many warnings to print to console (default `2` in dev, `5` otherwise) diff --git a/PowerForge.Tests/WebApiDocsGeneratorContractTests.cs b/PowerForge.Tests/WebApiDocsGeneratorContractTests.cs index d6f42dc5..61fa45ae 100644 --- a/PowerForge.Tests/WebApiDocsGeneratorContractTests.cs +++ b/PowerForge.Tests/WebApiDocsGeneratorContractTests.cs @@ -818,17 +818,29 @@ public void GenerateDocsHtml_WarnsWhenCssMissingScrollbarSelectors() .api-content{} .api-overview{} .api-overview-grid{} + .namespace-group-header{} + .namespace-group-actions{} + .overview-group-toggle{} .type-chips{} .type-chip{} .chip-icon{} .sidebar-count{} .sidebar-toggle{} + .pf-combobox{} + .pf-combobox-trigger{} + .pf-combobox-list{} + .pf-combobox-option{} + .pf-enhanced-native{} .type-item{} .type-detail-shell{} .type-detail-rail{} + .type-toc{} + .type-toc-header{} + .type-toc-toggle{} .filter-button{} .member-card{} .member-signature{} + .member-toggle input{} """); var outputPath = Path.Combine(root, "api"); diff --git a/PowerForge.Tests/WebApiDocsGeneratorSourceAndCssTests.cs b/PowerForge.Tests/WebApiDocsGeneratorSourceAndCssTests.cs index 4950e04d..3cc8fa47 100644 --- a/PowerForge.Tests/WebApiDocsGeneratorSourceAndCssTests.cs +++ b/PowerForge.Tests/WebApiDocsGeneratorSourceAndCssTests.cs @@ -162,6 +162,59 @@ public void GenerateDocsHtml_UsesThemeCssLinksWithoutInliningFallback_WhenCustom } } + [Fact] + public void GenerateDocsHtml_OmitsOverviewWorkspaceRail_WhenUsingDocsTemplate() + { + var root = Path.Combine(Path.GetTempPath(), "pf-webapidocs-overview-layout-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + var xmlPath = Path.Combine(root, "test.xml"); + File.WriteAllText(xmlPath, + """ + + Test + + + Sample. + + + + """); + + var cssPath = Path.Combine(root, "css", "api.css"); + Directory.CreateDirectory(Path.GetDirectoryName(cssPath)!); + File.WriteAllText(cssPath, ".api-layout { outline: 0; }"); + + var outputPath = Path.Combine(root, "api"); + var options = new WebApiDocsOptions + { + XmlPath = xmlPath, + OutputPath = outputPath, + Format = "html", + Template = "docs", + BaseUrl = "/api", + CssHref = "/css/api.css" + }; + + try + { + var result = WebApiDocsGenerator.Generate(options); + Assert.True(result.TypeCount > 0); + + var indexHtmlPath = Path.Combine(outputPath, "index.html"); + Assert.True(File.Exists(indexHtmlPath), "Expected index.html to be generated."); + var html = File.ReadAllText(indexHtmlPath); + + Assert.DoesNotContain("Workspace Stats", html, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Browse smarter", html, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Jump To", html, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDeleteDirectory(root); + } + } + [Fact] public void GenerateDocsHtml_SourceUrlMappings_UseMostSpecificPrefix_AndHonorStripPathPrefix() { diff --git a/PowerForge.Tests/WebCliApiDocsPreflightTests.cs b/PowerForge.Tests/WebCliApiDocsPreflightTests.cs index b7464300..bd26d434 100644 --- a/PowerForge.Tests/WebCliApiDocsPreflightTests.cs +++ b/PowerForge.Tests/WebCliApiDocsPreflightTests.cs @@ -100,6 +100,39 @@ public void HandleSubCommand_ApiDocs_FailsWhenNavSurfaceConfiguredWithoutNav() } } + [Fact] + public void HandleSubCommand_ApiDocs_FailsWhenSourceRootLooksOneLevelAboveGitHubRepo() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-cli-apidocs-preflight-root-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + Directory.CreateDirectory(Path.Combine(root, "TestRepo")); + var xmlPath = Path.Combine(root, "test.xml"); + File.WriteAllText(xmlPath, BuildMinimalXml()); + var outPath = Path.Combine(root, "_site", "api"); + + var args = new[] + { + "--type", "CSharp", + "--xml", xmlPath, + "--out", outPath, + "--format", "json", + "--fail-on-warnings", + "--source-root", root, + "--source-url", "https://github.com/EvotecIT/TestRepo/blob/main/{path}#L{line}" + }; + + var exitCode = WebCliCommandHandlers.HandleSubCommand("apidocs", args, outputJson: true, logger: new WebConsoleLogger(), outputSchemaVersion: 1); + Assert.Equal(2, exitCode); + } + finally + { + TryDeleteDirectory(root); + } + } + [Fact] public void HandleSubCommand_ApiDocs_FailsWhenLegacyAliasModeIsInvalid() { diff --git a/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs b/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs index 141d59fe..8690a28d 100644 --- a/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs +++ b/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs @@ -143,6 +143,50 @@ public void RunPipeline_ApiDocsPreflight_FailsWhenNavSurfaceConfiguredWithoutNav } } + [Fact] + public void RunPipeline_ApiDocsPreflight_FailsWhenSourceRootLooksOneLevelAboveGitHubRepo() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-pipeline-apidocs-preflight-root-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + Directory.CreateDirectory(Path.Combine(root, "TestRepo")); + var xmlPath = Path.Combine(root, "test.xml"); + File.WriteAllText(xmlPath, BuildMinimalXml()); + var pipelinePath = Path.Combine(root, "pipeline.json"); + File.WriteAllText(pipelinePath, + """ + { + "steps": [ + { + "task": "apidocs", + "type": "CSharp", + "xml": "./test.xml", + "out": "./_site/api", + "format": "json", + "failOnWarnings": true, + "sourceRoot": ".", + "sourceUrl": "https://github.com/EvotecIT/TestRepo/blob/main/{path}#L{line}" + } + ] + } + """); + + var result = WebPipelineRunner.RunPipeline(pipelinePath, logger: null); + + Assert.False(result.Success); + Assert.Single(result.Steps); + Assert.False(result.Steps[0].Success); + Assert.Contains("[PFWEB.APIDOCS.SOURCE]", result.Steps[0].Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("looks one level above repo 'TestRepo'", result.Steps[0].Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDeleteDirectory(root); + } + } + [Fact] public void RunPipeline_ApiDocs_RespectsMemberXrefKindsAndMaxPerType() { diff --git a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs index ec3fca23..1b62d627 100644 --- a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs +++ b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs @@ -568,6 +568,28 @@ internal static string[] ValidateApiDocsPreflight( warnings.Add("[PFWEB.APIDOCS.SOURCE] API docs source preflight: sourceUrl does not include a path token ({path}, {pathNoRoot}, or {pathNoPrefix})."); } + if (hasSourceRoot && + hasSourceUrl && + !hasMappings && + sourceUrl!.IndexOf("{root}", StringComparison.OrdinalIgnoreCase) < 0) + { + var fullSourceRoot = Path.GetFullPath(sourceRoot!); + if (Directory.Exists(fullSourceRoot) && + TryExtractGitHubRepoName(sourceUrl, out var repoName) && + !string.IsNullOrWhiteSpace(repoName)) + { + var sourceRootName = new DirectoryInfo(fullSourceRoot).Name; + var nestedRepoRoot = Path.Combine(fullSourceRoot, repoName); + if (!string.Equals(sourceRootName, repoName, StringComparison.OrdinalIgnoreCase) && + Directory.Exists(nestedRepoRoot)) + { + warnings.Add( + $"[PFWEB.APIDOCS.SOURCE] API docs source preflight: sourceRoot '{fullSourceRoot}' looks one level above repo '{repoName}'. " + + "Use the repo folder as sourceRoot, or switch sourceUrl to {root}/sourceUrlMappings for mixed-repo layouts."); + } + } + } + if (hasMappings) { var seenPrefixes = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -633,6 +655,31 @@ private static bool ContainsAnyPathToken(string value) value.IndexOf("{pathNoPrefix}", StringComparison.OrdinalIgnoreCase) >= 0; } + private static bool TryExtractGitHubRepoName(string pattern, out string repoName) + { + repoName = string.Empty; + if (string.IsNullOrWhiteSpace(pattern)) + return false; + + var candidate = pattern + .Replace("{path}", "sample/path.cs", StringComparison.OrdinalIgnoreCase) + .Replace("{pathNoRoot}", "sample/path.cs", StringComparison.OrdinalIgnoreCase) + .Replace("{pathNoPrefix}", "sample/path.cs", StringComparison.OrdinalIgnoreCase) + .Replace("{root}", "SampleRepo", StringComparison.OrdinalIgnoreCase) + .Replace("{line}", "1", StringComparison.OrdinalIgnoreCase); + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) + return false; + if (!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + return false; + + var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length < 2) + return false; + + repoName = segments[1]; + return !string.IsNullOrWhiteSpace(repoName); + } + private static List GetApiDocsCoverageThresholds(JsonElement step) { var thresholds = new List(); diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.Html.cs b/PowerForge.Web/Services/WebApiDocsGenerator.Html.cs index 4c86b882..c640a21f 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.Html.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.Html.cs @@ -1032,16 +1032,7 @@ private static string BuildDocsOverview( .GroupBy(static t => string.IsNullOrWhiteSpace(t.Namespace) ? "(global)" : t.Namespace) .OrderBy(static g => g.Key, StringComparer.OrdinalIgnoreCase) .ToList(); - var aliasCount = types.Sum(static type => type.Aliases - .Where(static alias => !string.IsNullOrWhiteSpace(alias)) - .Select(static alias => alias.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Count()); var primaryKindLabel = ResolvePrimaryKindLabel(types); - var primaryKindCount = types.Count(type => - string.Equals(NormalizeKind(type.Kind), NormalizeKind(primaryKindLabel), StringComparison.OrdinalIgnoreCase) || - (primaryKindLabel.Equals("Function", StringComparison.OrdinalIgnoreCase) && - string.Equals(NormalizeKind(type.Kind), "function", StringComparison.OrdinalIgnoreCase))); sb.AppendLine("
"); sb.AppendLine("
"); @@ -1049,8 +1040,7 @@ private static string BuildDocsOverview( sb.AppendLine($"

{System.Web.HttpUtility.HtmlEncode(overviewTitle)}

"); sb.AppendLine("

Complete API documentation auto-generated from source documentation.

"); sb.AppendLine("
"); - sb.AppendLine("
"); - sb.AppendLine("
"); + sb.AppendLine("
"); var mainTypes = GetMainTypes(types, options); if (mainTypes.Count > 0) @@ -1089,36 +1079,6 @@ private static string BuildDocsOverview( AppendOverviewNamespaceGroup(sb, group, baseUrl, typeDisplayNames); } sb.AppendLine(" "); - sb.AppendLine("
"); - sb.AppendLine(" "); sb.AppendLine("
"); sb.AppendLine("
"); return sb.ToString().TrimEnd(); @@ -1174,17 +1134,6 @@ private static void AppendOverviewNamespaceGroup( sb.AppendLine("
"); } - private static void AppendOverviewStat(StringBuilder sb, string label, string value) - { - if (string.IsNullOrWhiteSpace(label) || string.IsNullOrWhiteSpace(value)) - return; - - sb.AppendLine("
"); - sb.AppendLine($" {System.Web.HttpUtility.HtmlEncode(label)}"); - sb.AppendLine($" {System.Web.HttpUtility.HtmlEncode(value)}"); - sb.AppendLine("
"); - } - private static string BuildNamespaceAnchorId(string namespaceName) { var normalized = string.IsNullOrWhiteSpace(namespaceName) ? "global" : namespaceName.Trim().ToLowerInvariant(); diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.cs b/PowerForge.Web/Services/WebApiDocsGenerator.cs index f661be57..8d0b1700 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.cs @@ -258,18 +258,29 @@ public static partial class WebApiDocsGenerator ".api-sidebar", ".api-content", ".api-overview", - ".api-overview-grid", + ".namespace-group-header", + ".namespace-group-actions", + ".overview-group-toggle", ".type-chips", ".type-chip", ".chip-icon", ".sidebar-count", ".sidebar-toggle", + ".pf-combobox", + ".pf-combobox-trigger", + ".pf-combobox-list", + ".pf-combobox-option", + ".pf-enhanced-native", ".type-item", ".type-detail-shell", ".type-detail-rail", + ".type-toc", + ".type-toc-header", + ".type-toc-toggle", ".filter-button", ".member-card", ".member-signature", + ".member-toggle input", ".member-header pre.member-signature", ".member-card pre::-webkit-scrollbar", ".member-card pre::-webkit-scrollbar-track", From 8652823f2bb4df5f1a2d32d45914d79f82ebfdca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 08:54:21 +0100 Subject: [PATCH 02/25] Improve PowerShell API docs fallback examples --- Docs/PowerForge.Web.ApiDocs.md | 1 + .../WebApiDocsGeneratorPowerShellTests.cs | 125 ++++++++++ ...iDocsGenerator.Parse.PowerShellExamples.cs | 219 ++++++++++++++++-- 3 files changed, 324 insertions(+), 21 deletions(-) diff --git a/Docs/PowerForge.Web.ApiDocs.md b/Docs/PowerForge.Web.ApiDocs.md index 1a0256a2..624d04c9 100644 --- a/Docs/PowerForge.Web.ApiDocs.md +++ b/Docs/PowerForge.Web.ApiDocs.md @@ -361,4 +361,5 @@ Notes: - PowerShell syntax signatures append `[]` for command kinds that support common parameters, and docs pages render a dedicated `Common Parameters` section with an `about_CommonParameters` reference. - PowerShell fallback examples are enabled by default (`generatePowerShellFallbackExamples:true`) and can source snippets from `psExamplesPath` or discovered `Examples/` folders. +- When PowerShell help has no authored examples, generated fallback examples prefer the most user-friendly parameter sets and can emit multiple examples per command up to `powerShellFallbackExampleLimit`. - In pipeline `apidocs` steps, you can gate quality with coverage thresholds (for example `minPowerShellCodeExamplesPercent`, `minMemberSummaryPercent`) and enforce via `failOnCoverage:true`. diff --git a/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs b/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs index 16bb6e02..d10240ee 100644 --- a/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs +++ b/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs @@ -1002,6 +1002,131 @@ public void Generate_PowerShellHelp_ImportsFallbackExamplesFromScriptsWhenHelpHa } } + [Fact] + public void Generate_PowerShellHelp_GeneratesFallbackExamplesFromBestParameterSets() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-apidocs-powershell-generated-fallback-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var helpPath = Path.Combine(root, "Sample.Module-help.xml"); + File.WriteAllText(helpPath, + """ + + + + + Invoke-SampleFunction + Function + Invokes data. + + + + Invoke-SampleFunction + + Name + string + + + + Invoke-SampleFunction + + Id + int + + + + Invoke-SampleFunction + + InputObject + Sample.Module.Item + + + Credential + pscredential + + + + + + Name + Name value. + string + + + Id + Identifier. + int + + + InputObject + Pipeline object. + Sample.Module.Item + + + Credential + Credential. + pscredential + + + + + """); + + File.WriteAllText(Path.Combine(root, "Sample.Module.psd1"), + """ + @{ + CmdletsToExport = @() + FunctionsToExport = @('Invoke-SampleFunction') + AliasesToExport = @() + RootModule = 'Sample.Module.psm1' + } + """); + File.WriteAllText(Path.Combine(root, "Sample.Module.psm1"), "function Invoke-SampleFunction { param([string]$Name, [int]$Id, $InputObject, [pscredential]$Credential) }"); + + var outputPath = Path.Combine(root, "_site", "api", "powershell"); + var options = new WebApiDocsOptions + { + Type = ApiDocsType.PowerShell, + HelpPath = helpPath, + OutputPath = outputPath, + Title = "PowerShell API", + BaseUrl = "/api/powershell", + Template = "docs", + Format = "both", + PowerShellFallbackExampleLimitPerCommand = 2 + }; + + WebApiDocsGenerator.Generate(options); + + using var functionJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(outputPath, "types", "invoke-samplefunction.json"))); + var examples = functionJson.RootElement.GetProperty("examples").EnumerateArray().ToArray(); + var codeExamples = examples + .Where(ex => ex.GetProperty("kind").GetString() == "code") + .Select(ex => ex.GetProperty("text").GetString()) + .Where(static text => !string.IsNullOrWhiteSpace(text)) + .ToArray(); + var textExamples = examples + .Where(ex => ex.GetProperty("kind").GetString() == "text") + .Select(ex => ex.GetProperty("text").GetString()) + .Where(static text => !string.IsNullOrWhiteSpace(text)) + .ToArray(); + + Assert.Equal(2, codeExamples.Length); + Assert.Contains(codeExamples, example => example!.Contains("Invoke-SampleFunction -Name 'Name'", StringComparison.Ordinal)); + Assert.Contains(codeExamples, example => example!.Contains("Invoke-SampleFunction -Id 1", StringComparison.Ordinal)); + Assert.DoesNotContain(codeExamples, example => example!.Contains("-InputObject", StringComparison.Ordinal)); + Assert.Contains(textExamples, example => example!.Contains("parameter set 'ByName'", StringComparison.Ordinal)); + Assert.Contains(textExamples, example => example!.Contains("parameter set 'ById'", StringComparison.Ordinal)); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + [Fact] public void Generate_ApiDocs_WritesCoverageReport() { diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs b/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs index 9e17ab7d..846808cb 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs @@ -5,6 +5,8 @@ namespace PowerForge.Web; public static partial class WebApiDocsGenerator { + private sealed record GeneratedPowerShellExample(string Label, string Code); + private static readonly Regex PowerShellCommandTokenRegex = new( @"\b[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9][A-Za-z0-9_.-]*\b", RegexOptions.Compiled | RegexOptions.CultureInvariant, @@ -50,18 +52,17 @@ private static void AppendPowerShellFallbackExamples( continue; } - var fallback = BuildGeneratedPowerShellExample(type); - if (!string.IsNullOrWhiteSpace(fallback)) + foreach (var fallback in BuildGeneratedPowerShellExamples(type, limit)) { type.Examples.Add(new ApiExampleModel { Kind = "text", - Text = "Generated fallback example from command syntax." + Text = fallback.Label }); type.Examples.Add(new ApiExampleModel { Kind = "code", - Text = fallback + Text = fallback.Code }); } } @@ -253,15 +254,39 @@ private static string CapturePowerShellExampleSnippet(string[] lines, int startI return string.Join(Environment.NewLine, snippet).Trim(); } - private static string? BuildGeneratedPowerShellExample(ApiTypeModel type) + private static IReadOnlyList BuildGeneratedPowerShellExamples(ApiTypeModel type, int limit) { - if (type is null || string.IsNullOrWhiteSpace(type.Name)) - return null; + if (type is null || string.IsNullOrWhiteSpace(type.Name) || limit <= 0) + return Array.Empty(); + + var examples = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var methods = type.Methods + .Where(static method => method is not null) + .OrderByDescending(GetGeneratedPowerShellExampleScore) + .ThenBy(static method => method.Parameters.Count(static p => !p.IsOptional)) + .ThenBy(static method => method.Parameters.Count) + .ThenBy(static method => method.ParameterSetName ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var method in methods) + { + var code = BuildGeneratedPowerShellExample(type.Name, method); + if (string.IsNullOrWhiteSpace(code) || !seen.Add(code)) + continue; + + examples.Add(new GeneratedPowerShellExample(BuildGeneratedPowerShellExampleLabel(method), code)); + if (examples.Count >= limit) + break; + } + + return examples; + } - var method = type.Methods - .OrderByDescending(static m => m.Parameters.Count(static p => !p.IsOptional)) - .ThenByDescending(static m => m.Parameters.Count) - .FirstOrDefault(); + private static string? BuildGeneratedPowerShellExample(string commandName, ApiMemberModel? method) + { + if (string.IsNullOrWhiteSpace(commandName)) + return null; var parameters = method?.Parameters ?? new List(); var picked = parameters @@ -271,19 +296,16 @@ private static string CapturePowerShellExampleSnippet(string[] lines, int startI if (picked.Count == 0) { - var candidate = - parameters.FirstOrDefault(p => p.Name.Equals("Path", StringComparison.OrdinalIgnoreCase)) ?? - parameters.FirstOrDefault(p => p.Name.EndsWith("Path", StringComparison.OrdinalIgnoreCase)) ?? - parameters.FirstOrDefault(p => p.Name.Equals("ModuleName", StringComparison.OrdinalIgnoreCase)) ?? - parameters.FirstOrDefault(p => p.Name.Equals("Name", StringComparison.OrdinalIgnoreCase)) ?? - parameters.FirstOrDefault(p => p.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)) ?? - parameters.FirstOrDefault(); + var candidate = parameters + .OrderByDescending(GetGeneratedPowerShellExampleParameterScore) + .ThenBy(static p => p.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); if (candidate is not null) picked.Add(candidate); } - var parts = new List { type.Name }; + var parts = new List { commandName }; foreach (var parameter in picked) { if (string.IsNullOrWhiteSpace(parameter.Name)) @@ -298,6 +320,154 @@ private static string CapturePowerShellExampleSnippet(string[] lines, int startI return string.Join(" ", parts.Where(static p => !string.IsNullOrWhiteSpace(p))); } + private static string BuildGeneratedPowerShellExampleLabel(ApiMemberModel method) + { + if (!string.IsNullOrWhiteSpace(method?.ParameterSetName)) + return $"Generated fallback example from parameter set '{method.ParameterSetName}'."; + return "Generated fallback example from command syntax."; + } + + private static int GetGeneratedPowerShellExampleScore(ApiMemberModel method) + { + if (method is null) + return int.MinValue; + + var required = method.Parameters.Where(static p => !p.IsOptional).ToList(); + var optional = method.Parameters.Where(static p => p.IsOptional).ToList(); + var score = 0; + + if (required.Count == 0) + { + score += 14; + } + else + { + score += Math.Max(0, 36 - Math.Abs(required.Count - 1) * 10); + } + + if (required.Count <= 3) + score += 8; + if (required.Count > 4) + score -= (required.Count - 4) * 8; + + score += required.Sum(GetGeneratedPowerShellExampleParameterScore); + score += optional + .Select(GetGeneratedPowerShellExampleParameterScore) + .DefaultIfEmpty(0) + .Max() / 3; + + return score; + } + + private static int GetGeneratedPowerShellExampleParameterScore(ApiParameterModel? parameter) + { + if (parameter is null) + return int.MinValue; + + var score = 0; + var name = parameter.Name?.Trim() ?? string.Empty; + var type = parameter.Type?.Trim() ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(name)) + { + if (name.Equals("Name", StringComparison.OrdinalIgnoreCase) || + name.Equals("Path", StringComparison.OrdinalIgnoreCase) || + name.Equals("LiteralPath", StringComparison.OrdinalIgnoreCase) || + name.Equals("Id", StringComparison.OrdinalIgnoreCase) || + name.Equals("Mode", StringComparison.OrdinalIgnoreCase) || + name.Equals("Uri", StringComparison.OrdinalIgnoreCase)) + score += 30; + else if (name.EndsWith("Name", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("Path", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("Id", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("Uri", StringComparison.OrdinalIgnoreCase)) + score += 22; + + if (name.Equals("InputObject", StringComparison.OrdinalIgnoreCase) || + name.Equals("Credential", StringComparison.OrdinalIgnoreCase) || + name.Equals("Session", StringComparison.OrdinalIgnoreCase) || + name.Equals("CimSession", StringComparison.OrdinalIgnoreCase) || + name.Equals("PSSession", StringComparison.OrdinalIgnoreCase) || + name.Equals("ScriptBlock", StringComparison.OrdinalIgnoreCase)) + score -= 28; + + if (name.Equals("WhatIf", StringComparison.OrdinalIgnoreCase) || + name.Equals("Confirm", StringComparison.OrdinalIgnoreCase) || + name.Equals("Verbose", StringComparison.OrdinalIgnoreCase) || + name.Equals("Debug", StringComparison.OrdinalIgnoreCase) || + name.Equals("ErrorAction", StringComparison.OrdinalIgnoreCase) || + name.Equals("WarningAction", StringComparison.OrdinalIgnoreCase) || + name.Equals("InformationAction", StringComparison.OrdinalIgnoreCase) || + name.Equals("ProgressAction", StringComparison.OrdinalIgnoreCase) || + name.Equals("OutVariable", StringComparison.OrdinalIgnoreCase) || + name.Equals("OutBuffer", StringComparison.OrdinalIgnoreCase) || + name.Equals("PipelineVariable", StringComparison.OrdinalIgnoreCase)) + score -= 40; + } + + if (IsPowerShellFriendlyExampleType(type)) + score += 12; + else if (!string.IsNullOrWhiteSpace(type)) + score -= 16; + + if (parameter.PossibleValues.Count > 0) + score += 8; + if (IsPowerShellSwitchParameter(type)) + score -= 6; + + return score; + } + + private static bool IsPowerShellFriendlyExampleType(string? typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + return true; + + var type = typeName.Trim(); + if (type.EndsWith("[]", StringComparison.Ordinal)) + return IsPowerShellFriendlyExampleType(type[..^2]); + + return type.Equals("String", StringComparison.OrdinalIgnoreCase) || + type.Equals("string", StringComparison.OrdinalIgnoreCase) || + type.EndsWith(".String", StringComparison.OrdinalIgnoreCase) || + type.Equals("Int32", StringComparison.OrdinalIgnoreCase) || + type.Equals("int", StringComparison.OrdinalIgnoreCase) || + type.Equals("Int64", StringComparison.OrdinalIgnoreCase) || + type.Equals("long", StringComparison.OrdinalIgnoreCase) || + type.Equals("UInt32", StringComparison.OrdinalIgnoreCase) || + type.Equals("uint", StringComparison.OrdinalIgnoreCase) || + type.Equals("UInt64", StringComparison.OrdinalIgnoreCase) || + type.Equals("ulong", StringComparison.OrdinalIgnoreCase) || + type.Equals("Int16", StringComparison.OrdinalIgnoreCase) || + type.Equals("short", StringComparison.OrdinalIgnoreCase) || + type.Equals("UInt16", StringComparison.OrdinalIgnoreCase) || + type.Equals("ushort", StringComparison.OrdinalIgnoreCase) || + type.Equals("Byte", StringComparison.OrdinalIgnoreCase) || + type.Equals("byte", StringComparison.OrdinalIgnoreCase) || + type.Equals("SByte", StringComparison.OrdinalIgnoreCase) || + type.Equals("sbyte", StringComparison.OrdinalIgnoreCase) || + type.Equals("Double", StringComparison.OrdinalIgnoreCase) || + type.Equals("double", StringComparison.OrdinalIgnoreCase) || + type.Equals("Single", StringComparison.OrdinalIgnoreCase) || + type.Equals("float", StringComparison.OrdinalIgnoreCase) || + type.Equals("Decimal", StringComparison.OrdinalIgnoreCase) || + type.Equals("decimal", StringComparison.OrdinalIgnoreCase) || + type.Equals("Boolean", StringComparison.OrdinalIgnoreCase) || + type.Equals("Bool", StringComparison.OrdinalIgnoreCase) || + type.Equals("bool", StringComparison.OrdinalIgnoreCase) || + type.Equals("Guid", StringComparison.OrdinalIgnoreCase) || + type.Equals("guid", StringComparison.OrdinalIgnoreCase) || + type.Equals("Uri", StringComparison.OrdinalIgnoreCase) || + type.Equals("uri", StringComparison.OrdinalIgnoreCase) || + type.Equals("DateTime", StringComparison.OrdinalIgnoreCase) || + type.Equals("datetime", StringComparison.OrdinalIgnoreCase) || + type.Equals("Hashtable", StringComparison.OrdinalIgnoreCase) || + type.Equals("IDictionary", StringComparison.OrdinalIgnoreCase) || + type.Equals("ScriptBlock", StringComparison.OrdinalIgnoreCase) || + type.Equals("SwitchParameter", StringComparison.OrdinalIgnoreCase) || + type.EndsWith(".SwitchParameter", StringComparison.OrdinalIgnoreCase); + } + private static bool IsPowerShellSwitchParameter(string? typeName) => !string.IsNullOrWhiteSpace(typeName) && (typeName.Equals("SwitchParameter", StringComparison.OrdinalIgnoreCase) || @@ -340,9 +510,16 @@ private static string GetPowerShellSampleValue(string parameterName, string? typ if (type.Equals("Boolean", StringComparison.OrdinalIgnoreCase) || type.Equals("Bool", StringComparison.OrdinalIgnoreCase)) return "$true"; if (type.Equals("Int32", StringComparison.OrdinalIgnoreCase) || type.Equals("Int64", StringComparison.OrdinalIgnoreCase) || - type.Equals("UInt32", StringComparison.OrdinalIgnoreCase) || type.Equals("UInt64", StringComparison.OrdinalIgnoreCase)) + type.Equals("UInt32", StringComparison.OrdinalIgnoreCase) || type.Equals("UInt64", StringComparison.OrdinalIgnoreCase) || + type.Equals("Int16", StringComparison.OrdinalIgnoreCase) || type.Equals("UInt16", StringComparison.OrdinalIgnoreCase) || + type.Equals("Byte", StringComparison.OrdinalIgnoreCase) || type.Equals("SByte", StringComparison.OrdinalIgnoreCase) || + type.Equals("int", StringComparison.OrdinalIgnoreCase) || type.Equals("long", StringComparison.OrdinalIgnoreCase) || + type.Equals("uint", StringComparison.OrdinalIgnoreCase) || type.Equals("ulong", StringComparison.OrdinalIgnoreCase) || + type.Equals("short", StringComparison.OrdinalIgnoreCase) || type.Equals("ushort", StringComparison.OrdinalIgnoreCase) || + type.Equals("byte", StringComparison.OrdinalIgnoreCase) || type.Equals("sbyte", StringComparison.OrdinalIgnoreCase)) return "1"; - if (type.Equals("Double", StringComparison.OrdinalIgnoreCase) || type.Equals("Single", StringComparison.OrdinalIgnoreCase) || type.Equals("Decimal", StringComparison.OrdinalIgnoreCase)) + if (type.Equals("Double", StringComparison.OrdinalIgnoreCase) || type.Equals("Single", StringComparison.OrdinalIgnoreCase) || type.Equals("Decimal", StringComparison.OrdinalIgnoreCase) || + type.Equals("double", StringComparison.OrdinalIgnoreCase) || type.Equals("float", StringComparison.OrdinalIgnoreCase) || type.Equals("decimal", StringComparison.OrdinalIgnoreCase)) return "1"; if (type.Equals("DateTime", StringComparison.OrdinalIgnoreCase)) return "'2000-01-01'"; From 84cc125361fa52b811d13af3eebbfa5bb1dfa125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 09:19:31 +0100 Subject: [PATCH 03/25] Warn with sampled API source links --- .../WebApiDocsGeneratorSourceAndCssTests.cs | 8 +- ...iDocsGenerator.ApiDocs.SourceValidation.cs | 8 +- .../Services/WebApiDocsGenerator.Coverage.cs | 104 ++++++++++++++++++ .../Services/WebApiDocsGenerator.cs | 3 + 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/PowerForge.Tests/WebApiDocsGeneratorSourceAndCssTests.cs b/PowerForge.Tests/WebApiDocsGeneratorSourceAndCssTests.cs index 3cc8fa47..6c79ee6b 100644 --- a/PowerForge.Tests/WebApiDocsGeneratorSourceAndCssTests.cs +++ b/PowerForge.Tests/WebApiDocsGeneratorSourceAndCssTests.cs @@ -384,7 +384,9 @@ public void GenerateDocsHtml_WarnsWhenSourceUrlPatternLikelyDuplicatesPathPrefix var result = WebApiDocsGenerator.Generate(options); Assert.True(result.TypeCount > 0); Assert.Contains(result.Warnings, w => - w.Contains("detected likely duplicated path prefixes in GitHub source URLs", StringComparison.OrdinalIgnoreCase)); + w.Contains("detected likely duplicated path prefixes in GitHub source URLs", StringComparison.OrdinalIgnoreCase) && + w.Contains("Example URLs:", StringComparison.OrdinalIgnoreCase) && + w.Contains("https://github.com/example/PowerForge.Web/blob/main/PowerForge.Web/", StringComparison.OrdinalIgnoreCase)); } finally { @@ -467,6 +469,10 @@ public void GenerateDocsHtml_WarnsWhenSourceUrlMappingUsesUnsupportedToken() Assert.Contains(result.Warnings, w => w.Contains("[PFWEB.APIDOCS.SOURCE]", StringComparison.OrdinalIgnoreCase) && w.Contains("unsupported token(s): {branch}", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Warnings, w => + w.Contains("[PFWEB.APIDOCS.SOURCE]", StringComparison.OrdinalIgnoreCase) && + w.Contains("generated source URLs still contain unresolved template tokens", StringComparison.OrdinalIgnoreCase) && + w.Contains("{branch}", StringComparison.OrdinalIgnoreCase)); } finally { diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.ApiDocs.SourceValidation.cs b/PowerForge.Web/Services/WebApiDocsGenerator.ApiDocs.SourceValidation.cs index 2800e5fd..14cc7e19 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.ApiDocs.SourceValidation.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.ApiDocs.SourceValidation.cs @@ -192,6 +192,7 @@ private static void ValidateSourceUrlDuplicatePathHints( var hintCount = 0; var samples = new HashSet(StringComparer.OrdinalIgnoreCase); + var urlSamples = new HashSet(StringComparer.OrdinalIgnoreCase); void ObserveSource(ApiSourceLink? source) { @@ -214,6 +215,8 @@ void ObserveSource(ApiSourceLink? source) hintCount++; if (samples.Count < 8 && !string.IsNullOrWhiteSpace(hint)) samples.Add(hint); + if (urlSamples.Count < 4) + urlSamples.Add(source.Url.Trim()); } foreach (var type in types) @@ -232,9 +235,12 @@ void ObserveSource(ApiSourceLink? source) var samplePreview = string.Join(", ", samples.Take(4)); var more = samples.Count > 4 ? $" (+{samples.Count - 4} more samples)" : string.Empty; + var urlPreview = urlSamples.Count == 0 + ? string.Empty + : $" Example URLs: {string.Join(" | ", urlSamples.Take(2))}."; warnings.Add( $"API docs source: detected likely duplicated path prefixes in GitHub source URLs for {hintCount} symbol(s) (samples: {samplePreview}{more}). " + - "Check sourceUrl/sourceUrlMappings/sourcePathPrefix; in mixed layouts prefer pathNoPrefix with stripPathPrefix:true."); + $"Check sourceUrl/sourceUrlMappings/sourcePathPrefix; in mixed layouts prefer pathNoPrefix with stripPathPrefix:true.{urlPreview}"); } private static bool TryBuildPathDuplicationHint(string sourcePath, string githubFilePath, out string hint) diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs b/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs index bd49ba7d..bcfb6d04 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs @@ -36,6 +36,50 @@ public static partial class WebApiDocsGenerator } } + private static void AppendSourceCoverageWarnings( + IReadOnlyList types, + List warnings) + { + if (types is null || warnings is null) + return; + + var allMembers = types + .SelectMany(static t => t.Methods + .Concat(t.Constructors) + .Concat(t.Properties) + .Concat(t.Fields) + .Concat(t.Events) + .Concat(t.ExtensionMethods)) + .ToArray(); + var commandTypes = types.Where(IsPowerShellCommandType).ToArray(); + + var groups = new[] + { + new SourceCoverageGroup("types", AnalyzeSourceCoverage(types.Select(static t => t.Source))), + new SourceCoverageGroup("members", AnalyzeSourceCoverage(allMembers.Select(static m => m.Source))), + new SourceCoverageGroup("powershell", AnalyzeSourceCoverage(commandTypes.Select(static c => c.Source))) + }; + + AppendSourceCoverageWarning( + groups, + static group => group.Stats.UnresolvedTemplateTokenCount, + static group => group.Stats.UnresolvedTemplateSamples, + "generated source URLs still contain unresolved template tokens", + warnings); + AppendSourceCoverageWarning( + groups, + static group => group.Stats.InvalidUrlCount, + static group => group.Stats.InvalidUrlSamples, + "generated source URLs are not valid http/https URLs", + warnings); + AppendSourceCoverageWarning( + groups, + static group => group.Stats.RepoMismatchHintCount, + static group => group.Stats.RepoMismatchUrlSamples, + "generated GitHub source URLs look mismatched to the inferred repo and may 404", + warnings); + } + private static Dictionary BuildCoveragePayload( IReadOnlyList types, string? assemblyName, @@ -148,6 +192,43 @@ public static partial class WebApiDocsGenerator }; } + private static void AppendSourceCoverageWarning( + IReadOnlyList groups, + Func countSelector, + Func> sampleSelector, + string message, + List warnings) + { + if (groups is null || groups.Count == 0 || string.IsNullOrWhiteSpace(message) || warnings is null) + return; + + var active = groups + .Select(group => new + { + Group = group, + Count = Math.Max(0, countSelector(group)), + Samples = (sampleSelector(group) ?? Array.Empty()) + .Where(static sample => !string.IsNullOrWhiteSpace(sample)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }) + .Where(static entry => entry.Count > 0) + .ToArray(); + if (active.Length == 0) + return; + + var breakdown = string.Join(", ", active.Select(static entry => $"{entry.Group.Label} {entry.Count}")); + var samples = active + .SelectMany(static entry => entry.Samples) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(3) + .ToArray(); + var sampleText = samples.Length == 0 + ? string.Empty + : $" (samples: {string.Join(" | ", samples)})"; + warnings.Add($"API docs source coverage: {message} ({breakdown}){sampleText}."); + } + private static Dictionary BuildSourceCoveragePayload(int total, SourceCoverageStats coverage) { return new Dictionary @@ -221,6 +302,16 @@ private static SourceCoverageStats AnalyzeSourceCoverage(IEnumerable(); public string[] UnresolvedTemplateSamples { get; set; } = Array.Empty(); public string[] RepoMismatchSamples { get; set; } = Array.Empty(); + public string[] RepoMismatchUrlSamples { get; set; } = Array.Empty(); + } + + private sealed class SourceCoverageGroup + { + public SourceCoverageGroup(string label, SourceCoverageStats stats) + { + Label = label; + Stats = stats; + } + + public string Label { get; } + public SourceCoverageStats Stats { get; } } } diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.cs b/PowerForge.Web/Services/WebApiDocsGenerator.cs index 8d0b1700..7a990a9a 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.cs @@ -713,6 +713,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) ValidateCssContract(outputPath, options, warnings); } + AppendSourceCoverageWarnings(types, warnings); var coveragePath = WriteCoverageReport(outputPath, options, types, assemblyName, assemblyVersion, warnings); var xrefPath = WriteXrefMap(outputPath, options, types, assemblyName, assemblyVersion, warnings); @@ -788,6 +789,8 @@ private static string NormalizeWarningCode(string warning) return "[PFWEB.APIDOCS.SOURCE] " + warning; if (trimmed.StartsWith("SourceUrlPattern repo", StringComparison.OrdinalIgnoreCase)) return "[PFWEB.APIDOCS.SOURCE] " + warning; + if (trimmed.StartsWith("API docs source coverage:", StringComparison.OrdinalIgnoreCase)) + return "[PFWEB.APIDOCS.SOURCE] " + warning; if (trimmed.StartsWith("API docs source:", StringComparison.OrdinalIgnoreCase)) return "[PFWEB.APIDOCS.SOURCE] " + warning; From 30914a7085c5cb1645df692c1dee615c1563d014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 09:25:40 +0100 Subject: [PATCH 04/25] Warn on generated-only PowerShell examples --- Docs/PowerForge.Web.ApiDocs.md | 1 + .../WebApiDocsGeneratorPowerShellTests.cs | 85 +++++++++++++++++++ .../Services/WebApiDocsGenerator.Coverage.cs | 70 ++++++++++++++- .../Services/WebApiDocsGenerator.cs | 3 + 4 files changed, 158 insertions(+), 1 deletion(-) diff --git a/Docs/PowerForge.Web.ApiDocs.md b/Docs/PowerForge.Web.ApiDocs.md index 624d04c9..8c81a4f6 100644 --- a/Docs/PowerForge.Web.ApiDocs.md +++ b/Docs/PowerForge.Web.ApiDocs.md @@ -362,4 +362,5 @@ Notes: and docs pages render a dedicated `Common Parameters` section with an `about_CommonParameters` reference. - PowerShell fallback examples are enabled by default (`generatePowerShellFallbackExamples:true`) and can source snippets from `psExamplesPath` or discovered `Examples/` folders. - When PowerShell help has no authored examples, generated fallback examples prefer the most user-friendly parameter sets and can emit multiple examples per command up to `powerShellFallbackExampleLimit`. +- API docs now emit `[PFWEB.APIDOCS.POWERSHELL]` warnings when PowerShell commands rely only on generated fallback examples, so CI can distinguish “has some example” from “has authored examples”. - In pipeline `apidocs` steps, you can gate quality with coverage thresholds (for example `minPowerShellCodeExamplesPercent`, `minMemberSummaryPercent`) and enforce via `failOnCoverage:true`. diff --git a/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs b/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs index d10240ee..25f2e220 100644 --- a/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs +++ b/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs @@ -1176,4 +1176,89 @@ public void Generate_ApiDocs_WritesCoverageReport() Directory.Delete(root, true); } } + + [Fact] + public void Generate_PowerShellHelp_WarnsWhenCommandsRelyOnlyOnGeneratedFallbackExamples() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-apidocs-powershell-generated-warning-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var helpPath = Path.Combine(root, "Sample.Module-help.xml"); + File.WriteAllText(helpPath, + """ + + + + + Invoke-SampleFunction + Function + Invokes data. + + + + Invoke-SampleFunction + + Name + string + + + + + + Name + Name value. + string + + + + + """); + + File.WriteAllText(Path.Combine(root, "Sample.Module.psd1"), + """ + @{ + CmdletsToExport = @() + FunctionsToExport = @('Invoke-SampleFunction') + AliasesToExport = @() + RootModule = 'Sample.Module.psm1' + } + """); + File.WriteAllText(Path.Combine(root, "Sample.Module.psm1"), "function Invoke-SampleFunction { param([string]$Name) }"); + + var outputPath = Path.Combine(root, "_site", "api", "powershell"); + var options = new WebApiDocsOptions + { + Type = ApiDocsType.PowerShell, + HelpPath = helpPath, + OutputPath = outputPath, + Title = "PowerShell API", + BaseUrl = "/api/powershell", + Format = "json", + CoverageReportPath = "reports/api-coverage.json" + }; + + var result = WebApiDocsGenerator.Generate(options); + + Assert.Contains(result.Warnings, warning => + warning.Contains("[PFWEB.APIDOCS.POWERSHELL]", StringComparison.OrdinalIgnoreCase) && + warning.Contains("rely only on generated fallback examples", StringComparison.OrdinalIgnoreCase) && + warning.Contains("Invoke-SampleFunction", StringComparison.OrdinalIgnoreCase)); + + using var coverage = JsonDocument.Parse(File.ReadAllText(result.CoveragePath!)); + var generatedFallbackOnly = coverage.RootElement + .GetProperty("powershell") + .GetProperty("generatedFallbackOnlyExamples"); + Assert.Equal(1, generatedFallbackOnly.GetProperty("covered").GetInt32()); + Assert.Contains( + coverage.RootElement.GetProperty("powershell").GetProperty("commandsUsingGeneratedFallbackOnlyExamples").EnumerateArray().Select(x => x.GetString()), + value => string.Equals(value, "Invoke-SampleFunction", StringComparison.Ordinal)); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } } diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs b/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs index bcfb6d04..5b4172a0 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs @@ -80,6 +80,32 @@ private static void AppendSourceCoverageWarnings( warnings); } + private static void AppendPowerShellExampleQualityWarnings( + IReadOnlyList types, + List warnings) + { + if (types is null || warnings is null) + return; + + var commands = types.Where(IsPowerShellCommandType).ToArray(); + if (commands.Length == 0) + return; + + var generatedOnly = commands + .Where(HasOnlyGeneratedPowerShellFallbackExamples) + .Select(static c => c.FullName) + .OrderBy(static c => c, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (generatedOnly.Length == 0) + return; + + var preview = string.Join(", ", generatedOnly.Take(4)); + var more = generatedOnly.Length > 4 ? $" (+{generatedOnly.Length - 4} more)" : string.Empty; + warnings.Add( + $"API docs PowerShell coverage: {generatedOnly.Length} command(s) rely only on generated fallback examples. " + + $"Add authored examples or example scripts for better docs quality (samples: {preview}{more})."); + } + private static Dictionary BuildCoveragePayload( IReadOnlyList types, string? assemblyName, @@ -121,6 +147,7 @@ private static void AppendSourceCoverageWarnings( var commandsWithCodeExamples = commandTypes.Count(static c => c.Examples.Any(static ex => ex.Kind.Equals("code", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(ex.Text))); + var commandsWithGeneratedFallbackOnlyExamples = commandTypes.Count(HasOnlyGeneratedPowerShellFallbackExamples); var commandSourceCoverage = AnalyzeSourceCoverage(commandTypes.Select(static c => c.Source)); var commandsMissingExamples = commandTypes @@ -131,6 +158,12 @@ private static void AppendSourceCoverageWarnings( .OrderBy(static c => c, StringComparer.OrdinalIgnoreCase) .Take(100) .ToArray(); + var commandsUsingGeneratedFallbackOnlyExamples = commandTypes + .Where(HasOnlyGeneratedPowerShellFallbackExamples) + .Select(static c => c.FullName) + .OrderBy(static c => c, StringComparer.OrdinalIgnoreCase) + .Take(100) + .ToArray(); var commandParameterCoverage = commandTypes.Select(static c => new { @@ -186,8 +219,10 @@ private static void AppendSourceCoverageWarnings( ["summary"] = MakeCoverage(commandCount, commandsWithSummary), ["remarks"] = MakeCoverage(commandCount, commandsWithRemarks), ["codeExamples"] = MakeCoverage(commandCount, commandsWithCodeExamples), + ["generatedFallbackOnlyExamples"] = MakeCoverage(commandCount, commandsWithGeneratedFallbackOnlyExamples), ["parameters"] = MakeCoverage(commandParameterCount, commandParametersWithSummary), - ["commandsMissingCodeExamples"] = commandsMissingExamples + ["commandsMissingCodeExamples"] = commandsMissingExamples, + ["commandsUsingGeneratedFallbackOnlyExamples"] = commandsUsingGeneratedFallbackOnlyExamples } }; } @@ -229,6 +264,39 @@ private static void AppendSourceCoverageWarning( warnings.Add($"API docs source coverage: {message} ({breakdown}){sampleText}."); } + private static bool HasOnlyGeneratedPowerShellFallbackExamples(ApiTypeModel type) + { + if (type is null || type.Examples.Count == 0) + return false; + + var hasCode = false; + var hasGeneratedFallbackCode = false; + for (var i = 0; i < type.Examples.Count; i++) + { + var example = type.Examples[i]; + if (example is null || + !example.Kind.Equals("code", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(example.Text)) + continue; + + hasCode = true; + var generated = i > 0 && + type.Examples[i - 1] is not null && + type.Examples[i - 1].Kind.Equals("text", StringComparison.OrdinalIgnoreCase) && + IsGeneratedPowerShellFallbackLabel(type.Examples[i - 1].Text); + if (!generated) + return false; + + hasGeneratedFallbackCode = true; + } + + return hasCode && hasGeneratedFallbackCode; + } + + private static bool IsGeneratedPowerShellFallbackLabel(string? text) + => !string.IsNullOrWhiteSpace(text) && + text.TrimStart().StartsWith("Generated fallback example", StringComparison.OrdinalIgnoreCase); + private static Dictionary BuildSourceCoveragePayload(int total, SourceCoverageStats coverage) { return new Dictionary diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.cs b/PowerForge.Web/Services/WebApiDocsGenerator.cs index 7a990a9a..3d10931e 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.cs @@ -714,6 +714,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) } AppendSourceCoverageWarnings(types, warnings); + AppendPowerShellExampleQualityWarnings(types, warnings); var coveragePath = WriteCoverageReport(outputPath, options, types, assemblyName, assemblyVersion, warnings); var xrefPath = WriteXrefMap(outputPath, options, types, assemblyName, assemblyVersion, warnings); @@ -793,6 +794,8 @@ private static string NormalizeWarningCode(string warning) return "[PFWEB.APIDOCS.SOURCE] " + warning; if (trimmed.StartsWith("API docs source:", StringComparison.OrdinalIgnoreCase)) return "[PFWEB.APIDOCS.SOURCE] " + warning; + if (trimmed.StartsWith("API docs PowerShell coverage:", StringComparison.OrdinalIgnoreCase)) + return "[PFWEB.APIDOCS.POWERSHELL] " + warning; if (trimmed.StartsWith("Failed to parse PowerShell help:", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("Multiple PowerShell help files found", StringComparison.OrdinalIgnoreCase)) From 66ebcced08043fc8ec27bdeb0b223aedb3c9e89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 09:29:12 +0100 Subject: [PATCH 05/25] Prefer command-specific PowerShell examples --- Docs/PowerForge.Web.ApiDocs.md | 1 + .../WebApiDocsGeneratorPowerShellTests.cs | 95 +++++++++++++++++++ ...iDocsGenerator.Parse.PowerShellExamples.cs | 94 +++++++++++++++++- 3 files changed, 186 insertions(+), 4 deletions(-) diff --git a/Docs/PowerForge.Web.ApiDocs.md b/Docs/PowerForge.Web.ApiDocs.md index 8c81a4f6..5e86d628 100644 --- a/Docs/PowerForge.Web.ApiDocs.md +++ b/Docs/PowerForge.Web.ApiDocs.md @@ -361,6 +361,7 @@ Notes: - PowerShell syntax signatures append `[]` for command kinds that support common parameters, and docs pages render a dedicated `Common Parameters` section with an `about_CommonParameters` reference. - PowerShell fallback examples are enabled by default (`generatePowerShellFallbackExamples:true`) and can source snippets from `psExamplesPath` or discovered `Examples/` folders. +- When importing script-based fallback examples, command-specific files (for example `Invoke-Thing.ps1`) are preferred over generic demo scripts when multiple snippets match the same command. - When PowerShell help has no authored examples, generated fallback examples prefer the most user-friendly parameter sets and can emit multiple examples per command up to `powerShellFallbackExampleLimit`. - API docs now emit `[PFWEB.APIDOCS.POWERSHELL]` warnings when PowerShell commands rely only on generated fallback examples, so CI can distinguish “has some example” from “has authored examples”. - In pipeline `apidocs` steps, you can gate quality with coverage thresholds (for example `minPowerShellCodeExamplesPercent`, `minMemberSummaryPercent`) and enforce via `failOnCoverage:true`. diff --git a/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs b/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs index 25f2e220..4e6fac71 100644 --- a/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs +++ b/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs @@ -1002,6 +1002,101 @@ public void Generate_PowerShellHelp_ImportsFallbackExamplesFromScriptsWhenHelpHa } } + [Fact] + public void Generate_PowerShellHelp_PrefersCommandSpecificExampleScriptsOverGenericOnes() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-apidocs-powershell-fallback-ranking-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var helpPath = Path.Combine(root, "en-US", "Sample.Module-help.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(helpPath)!); + File.WriteAllText(helpPath, + """ + + + + + Invoke-SampleFunction + Function + Invokes data. + + + + Invoke-SampleFunction + + Name + string + + + + + + Name + Name value. + string + + + + + """); + + File.WriteAllText(Path.Combine(root, "Sample.Module.psd1"), + """ + @{ + CmdletsToExport = @() + FunctionsToExport = @('Invoke-SampleFunction') + AliasesToExport = @() + RootModule = 'Sample.Module.psm1' + } + """); + File.WriteAllText(Path.Combine(root, "Sample.Module.psm1"), "function Invoke-SampleFunction { param([string]$Name) }"); + + var examplesDir = Path.Combine(root, "Examples"); + Directory.CreateDirectory(examplesDir); + File.WriteAllText(Path.Combine(examplesDir, "Example.Generic.ps1"), + """ + Invoke-SampleFunction -Name "FromGeneric" + """); + File.WriteAllText(Path.Combine(examplesDir, "Invoke-SampleFunction.ps1"), + """ + Invoke-SampleFunction -Name "FromSpecific" + """); + + var outputPath = Path.Combine(root, "_site", "api", "powershell"); + var options = new WebApiDocsOptions + { + Type = ApiDocsType.PowerShell, + HelpPath = root, + OutputPath = outputPath, + Title = "PowerShell API", + BaseUrl = "/api/powershell", + Template = "docs", + Format = "both", + PowerShellFallbackExampleLimitPerCommand = 1 + }; + + WebApiDocsGenerator.Generate(options); + using var functionJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(outputPath, "types", "invoke-samplefunction.json"))); + var examples = functionJson.RootElement.GetProperty("examples").EnumerateArray().ToArray(); + var codeExamples = examples + .Where(ex => ex.GetProperty("kind").GetString() == "code") + .Select(ex => ex.GetProperty("text").GetString()) + .Where(static text => !string.IsNullOrWhiteSpace(text)) + .ToArray(); + + Assert.Single(codeExamples); + Assert.Contains("FromSpecific", codeExamples[0], StringComparison.Ordinal); + Assert.DoesNotContain("FromGeneric", codeExamples[0], StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + [Fact] public void Generate_PowerShellHelp_GeneratesFallbackExamplesFromBestParameterSets() { diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs b/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs index 846808cb..70069f98 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs @@ -6,6 +6,7 @@ namespace PowerForge.Web; public static partial class WebApiDocsGenerator { private sealed record GeneratedPowerShellExample(string Label, string Code); + private sealed record ImportedPowerShellExampleCandidate(string Command, string Snippet, string FilePath, int Score, int LineNumber); private static readonly Regex PowerShellCommandTokenRegex = new( @"\b[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9][A-Za-z0-9_.-]*\b", @@ -161,6 +162,7 @@ private static Dictionary> CollectPowerShellExamplesFromScr { var results = new Dictionary>(StringComparer.OrdinalIgnoreCase); var dedupe = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var candidates = new Dictionary>(StringComparer.OrdinalIgnoreCase); if (files.Count == 0 || commandNames.Count == 0) return results; @@ -209,20 +211,104 @@ private static Dictionary> CollectPowerShellExamplesFromScr if (!dedupeSet.Add(snippet)) continue; - if (!results.TryGetValue(command, out var snippets)) + if (!candidates.TryGetValue(command, out var commandCandidates)) { - snippets = new List(); - results[command] = snippets; + commandCandidates = new List(); + candidates[command] = commandCandidates; } - snippets.Add(snippet); + commandCandidates.Add(new ImportedPowerShellExampleCandidate( + command, + snippet, + file, + GetImportedPowerShellExampleScore(command, file, snippet, i), + i)); } } } + foreach (var pair in candidates) + { + results[pair.Key] = pair.Value + .OrderByDescending(static candidate => candidate.Score) + .ThenBy(static candidate => candidate.FilePath, StringComparer.OrdinalIgnoreCase) + .ThenBy(static candidate => candidate.LineNumber) + .Take(Math.Max(1, maxPerCommand)) + .Select(static candidate => candidate.Snippet) + .ToList(); + } + return results; } + private static int GetImportedPowerShellExampleScore(string commandName, string filePath, string snippet, int lineNumber) + { + var score = 0; + var normalizedCommand = NormalizePowerShellExampleToken(commandName); + var commandNoun = GetPowerShellCommandNoun(commandName); + var normalizedNoun = NormalizePowerShellExampleToken(commandNoun); + var fileName = Path.GetFileNameWithoutExtension(filePath) ?? string.Empty; + var normalizedFileName = NormalizePowerShellExampleToken(fileName); + var normalizedPath = NormalizePowerShellExampleToken(filePath.Replace('\\', '/')); + + if (!string.IsNullOrWhiteSpace(normalizedCommand)) + { + if (string.Equals(normalizedFileName, normalizedCommand, StringComparison.Ordinal)) + score += 140; + else if (normalizedFileName.Contains(normalizedCommand, StringComparison.Ordinal)) + score += 100; + + if (normalizedPath.Contains(normalizedCommand, StringComparison.Ordinal)) + score += 35; + } + + if (!string.IsNullOrWhiteSpace(normalizedNoun)) + { + if (string.Equals(normalizedFileName, normalizedNoun, StringComparison.Ordinal)) + score += 70; + else if (normalizedFileName.Contains(normalizedNoun, StringComparison.Ordinal)) + score += 35; + } + + if (!string.IsNullOrWhiteSpace(snippet)) + { + var firstLine = snippet + .Split(new[] { "\r\n", "\n" }, StringSplitOptions.None) + .FirstOrDefault(static line => !string.IsNullOrWhiteSpace(line)) + ?.Trim() ?? string.Empty; + if (firstLine.StartsWith(commandName + " ", StringComparison.OrdinalIgnoreCase) || + string.Equals(firstLine, commandName, StringComparison.OrdinalIgnoreCase)) + score += 18; + } + + score -= Math.Max(0, lineNumber); + return score; + } + + private static string NormalizePowerShellExampleToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var chars = value + .Where(static ch => char.IsLetterOrDigit(ch)) + .Select(static ch => char.ToLowerInvariant(ch)) + .ToArray(); + return new string(chars); + } + + private static string GetPowerShellCommandNoun(string? commandName) + { + if (string.IsNullOrWhiteSpace(commandName)) + return string.Empty; + + var dash = commandName.IndexOf('-', StringComparison.Ordinal); + if (dash < 0 || dash + 1 >= commandName.Length) + return commandName; + + return commandName[(dash + 1)..]; + } + private static string CapturePowerShellExampleSnippet(string[] lines, int startIndex) { if (lines is null || startIndex < 0 || startIndex >= lines.Length) From 92ec9807bf393b24647cbe7851561aa6ce85dfc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 09:33:11 +0100 Subject: [PATCH 06/25] Gate generated-only PowerShell examples --- Docs/PowerForge.Web.ApiDocs.md | 1 + Docs/PowerForge.Web.Pipeline.md | 1 + .../WebPipelineRunnerApiDocsPreflightTests.cs | 86 +++++++++++++++++++ .../WebPipelineRunner.Tasks.Content.cs | 7 +- 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/Docs/PowerForge.Web.ApiDocs.md b/Docs/PowerForge.Web.ApiDocs.md index 5e86d628..e0c147a8 100644 --- a/Docs/PowerForge.Web.ApiDocs.md +++ b/Docs/PowerForge.Web.ApiDocs.md @@ -364,4 +364,5 @@ Notes: - When importing script-based fallback examples, command-specific files (for example `Invoke-Thing.ps1`) are preferred over generic demo scripts when multiple snippets match the same command. - When PowerShell help has no authored examples, generated fallback examples prefer the most user-friendly parameter sets and can emit multiple examples per command up to `powerShellFallbackExampleLimit`. - API docs now emit `[PFWEB.APIDOCS.POWERSHELL]` warnings when PowerShell commands rely only on generated fallback examples, so CI can distinguish “has some example” from “has authored examples”. +- Pipeline coverage thresholds can now gate that metric via `maxPowerShellGeneratedFallbackOnlyExamplePercent` or `maxPowerShellGeneratedFallbackOnlyExampleCount`. - In pipeline `apidocs` steps, you can gate quality with coverage thresholds (for example `minPowerShellCodeExamplesPercent`, `minMemberSummaryPercent`) and enforce via `failOnCoverage:true`. diff --git a/Docs/PowerForge.Web.Pipeline.md b/Docs/PowerForge.Web.Pipeline.md index d231349b..a46518b1 100644 --- a/Docs/PowerForge.Web.Pipeline.md +++ b/Docs/PowerForge.Web.Pipeline.md @@ -312,6 +312,7 @@ Notes: - `memberXrefMaxPerType`: optional cap for member xref entries per type/command (`0` = unlimited) - coverage thresholds (0-100): `minTypeSummaryPercent`, `minTypeRemarksPercent`, `minTypeCodeExamplesPercent`, `minMemberSummaryPercent`, `minMemberCodeExamplesPercent`, `minPowerShellSummaryPercent`, `minPowerShellRemarksPercent`, `minPowerShellCodeExamplesPercent`, `minPowerShellParameterSummaryPercent` - source coverage thresholds (0-100): `minTypeSourcePathPercent`, `minTypeSourceUrlPercent`, `minMemberSourcePathPercent`, `minMemberSourceUrlPercent`, `minPowerShellSourcePathPercent`, `minPowerShellSourceUrlPercent` + - PowerShell example quality thresholds: `maxPowerShellGeneratedFallbackOnlyExamplePercent` (0-100), `maxPowerShellGeneratedFallbackOnlyExampleCount` (>=0) - source quality max-count thresholds (>=0): `maxTypeSourceInvalidUrlCount`, `maxMemberSourceInvalidUrlCount`, `maxPowerShellSourceInvalidUrlCount`, `maxTypeSourceUnresolvedTemplateCount`, `maxMemberSourceUnresolvedTemplateCount`, `maxPowerShellSourceUnresolvedTemplateCount`, `maxTypeSourceRepoMismatchHints`, `maxMemberSourceRepoMismatchHints`, `maxPowerShellSourceRepoMismatchHints` - `failOnCoverage`: fail step when thresholds are below minimums (default: `true` when any threshold is configured) - `coveragePreviewCount`: max failed coverage metrics shown in logs diff --git a/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs b/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs index 8690a28d..191e83f0 100644 --- a/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs +++ b/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs @@ -273,6 +273,59 @@ public void RunPipeline_ApiDocs_FailsWhenLegacyAliasModeIsInvalid() } } + [Fact] + public void RunPipeline_ApiDocs_FailsWhenGeneratedOnlyPowerShellExamplesExceedThreshold() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-pipeline-apidocs-powershell-generated-threshold-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var helpPath = Path.Combine(root, "Sample.Module-help.xml"); + File.WriteAllText(helpPath, BuildMinimalPowerShellHelpWithoutExamples()); + File.WriteAllText(Path.Combine(root, "Sample.Module.psd1"), + """ + @{ + CmdletsToExport = @() + FunctionsToExport = @('Invoke-SampleFunction') + AliasesToExport = @() + RootModule = 'Sample.Module.psm1' + } + """); + File.WriteAllText(Path.Combine(root, "Sample.Module.psm1"), "function Invoke-SampleFunction { param([string]$Name) }"); + + var pipelinePath = Path.Combine(root, "pipeline.json"); + File.WriteAllText(pipelinePath, + """ + { + "steps": [ + { + "task": "apidocs", + "type": "PowerShell", + "help": "./Sample.Module-help.xml", + "out": "./_site/api", + "format": "json", + "failOnCoverage": true, + "maxPowerShellGeneratedFallbackOnlyExampleCount": 0 + } + ] + } + """); + + var result = WebPipelineRunner.RunPipeline(pipelinePath, logger: null); + + Assert.False(result.Success); + Assert.Single(result.Steps); + Assert.False(result.Steps[0].Success); + Assert.Contains("PowerShell generated-only fallback example count", result.Steps[0].Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exceeds allowed 0", result.Steps[0].Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDeleteDirectory(root); + } + } + private static string BuildMinimalXml() { return @@ -314,6 +367,39 @@ private static string BuildMinimalXmlWithMembers() """; } + private static string BuildMinimalPowerShellHelpWithoutExamples() + { + return + """ + + + + + Invoke-SampleFunction + Function + Invokes data. + + + + Invoke-SampleFunction + + Name + string + + + + + + Name + Name value. + string + + + + + """; + } + private static void TryDeleteDirectory(string path) { try diff --git a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs index 1b62d627..288490ad 100644 --- a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs +++ b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs @@ -698,6 +698,8 @@ private static List GetApiDocsCoverageThresholds(JsonE AddCoverageMinPercentThreshold(thresholds, step, "minMemberSourceUrlPercent", "min-member-source-url-percent", "source.members.url.percent", "Member source URL coverage"); AddCoverageMinPercentThreshold(thresholds, step, "minPowerShellSourcePathPercent", "min-powershell-source-path-percent", "source.powershell.path.percent", "PowerShell command source path coverage", powerShellCommandMetric: true); AddCoverageMinPercentThreshold(thresholds, step, "minPowerShellSourceUrlPercent", "min-powershell-source-url-percent", "source.powershell.url.percent", "PowerShell command source URL coverage", powerShellCommandMetric: true); + AddCoverageMaxThreshold(thresholds, step, "maxPowerShellGeneratedFallbackOnlyExamplePercent", "max-powershell-generated-fallback-only-example-percent", "powershell.generatedFallbackOnlyExamples.percent", "PowerShell generated-only fallback example percent", powerShellCommandMetric: true, formatAsPercent: true); + AddCoverageMaxThreshold(thresholds, step, "maxPowerShellGeneratedFallbackOnlyExampleCount", "max-powershell-generated-fallback-only-example-count", "powershell.generatedFallbackOnlyExamples.covered", "PowerShell generated-only fallback example count", powerShellCommandMetric: true); AddCoverageMaxThreshold(thresholds, step, "maxTypeSourceInvalidUrlCount", "max-type-source-invalid-url-count", "source.types.invalidUrl.count", "Type source invalid URL count"); AddCoverageMaxThreshold(thresholds, step, "maxMemberSourceInvalidUrlCount", "max-member-source-invalid-url-count", "source.members.invalidUrl.count", "Member source invalid URL count"); AddCoverageMaxThreshold(thresholds, step, "maxPowerShellSourceInvalidUrlCount", "max-powershell-source-invalid-url-count", "source.powershell.invalidUrl.count", "PowerShell command source invalid URL count", powerShellCommandMetric: true); @@ -744,7 +746,8 @@ private static void AddCoverageMaxThreshold( string aliasName, string metricPath, string label, - bool powerShellCommandMetric = false) + bool powerShellCommandMetric = false, + bool formatAsPercent = false) { var value = GetDouble(step, primaryName) ?? GetDouble(step, aliasName); if (!value.HasValue) @@ -759,7 +762,7 @@ private static void AddCoverageMaxThreshold( MetricPath = metricPath, TargetValue = value.Value, Comparison = ApiDocsCoverageComparison.Maximum, - FormatAsPercent = false, + FormatAsPercent = formatAsPercent, SkipWhenNoPowerShellCommands = powerShellCommandMetric }); } From 1c1bb72c819ba265cf14ac8d0c4604fc0a370907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 09:45:44 +0100 Subject: [PATCH 07/25] Track PowerShell example provenance in API coverage --- Docs/PowerForge.Web.ApiDocs.md | 5 + .../WebApiDocsGeneratorPowerShellTests.cs | 177 ++++++++++++++++++ .../WebApiDocsGenerator.ApiDocs.Models.cs | 8 + .../Services/WebApiDocsGenerator.Coverage.cs | 80 +++++--- ...iDocsGenerator.Parse.PowerShellExamples.cs | 9 +- .../Services/WebApiDocsGenerator.Parse.cs | 15 +- .../Services/WebApiDocsGenerator.cs | 68 ++++--- 7 files changed, 290 insertions(+), 72 deletions(-) diff --git a/Docs/PowerForge.Web.ApiDocs.md b/Docs/PowerForge.Web.ApiDocs.md index e0c147a8..a1d64210 100644 --- a/Docs/PowerForge.Web.ApiDocs.md +++ b/Docs/PowerForge.Web.ApiDocs.md @@ -364,5 +364,10 @@ Notes: - When importing script-based fallback examples, command-specific files (for example `Invoke-Thing.ps1`) are preferred over generic demo scripts when multiple snippets match the same command. - When PowerShell help has no authored examples, generated fallback examples prefer the most user-friendly parameter sets and can emit multiple examples per command up to `powerShellFallbackExampleLimit`. - API docs now emit `[PFWEB.APIDOCS.POWERSHELL]` warnings when PowerShell commands rely only on generated fallback examples, so CI can distinguish “has some example” from “has authored examples”. +- PowerShell `examples` entries in generated JSON now include an `origin` field when PowerForge can identify provenance: + - `AuthoredHelp` for examples from MAML help XML + - `ImportedScript` for examples imported from `psExamplesPath` / `Examples/` + - `GeneratedFallback` for auto-generated fallback examples +- Coverage reports now split PowerShell example coverage into `authoredHelpCodeExamples`, `importedScriptCodeExamples`, and `generatedFallbackCodeExamples`, alongside the existing `generatedFallbackOnlyExamples` guardrail. - Pipeline coverage thresholds can now gate that metric via `maxPowerShellGeneratedFallbackOnlyExamplePercent` or `maxPowerShellGeneratedFallbackOnlyExampleCount`. - In pipeline `apidocs` steps, you can gate quality with coverage thresholds (for example `minPowerShellCodeExamplesPercent`, `minMemberSummaryPercent`) and enforce via `failOnCoverage:true`. diff --git a/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs b/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs index 4e6fac71..9cafcca8 100644 --- a/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs +++ b/PowerForge.Tests/WebApiDocsGeneratorPowerShellTests.cs @@ -106,6 +106,7 @@ public void Generate_PowerShellHelp_RendersExamplesAndNormalizesModuleNamespace( ex => ex.GetProperty("kind").GetString() == "heading"); Assert.Contains(examples.EnumerateArray(), ex => ex.GetProperty("kind").GetString() == "code" && + ex.GetProperty("origin").GetString() == "AuthoredHelp" && ex.GetProperty("text").GetString()!.Contains("New-SampleCmdlet -Name \"Demo\"", StringComparison.Ordinal)); Assert.True(rootElement.TryGetProperty("inputTypes", out _)); Assert.True(rootElement.TryGetProperty("outputTypes", out _)); @@ -993,6 +994,7 @@ public void Generate_PowerShellHelp_ImportsFallbackExamplesFromScriptsWhenHelpHa var examples = functionJson.RootElement.GetProperty("examples").EnumerateArray().ToArray(); Assert.Contains(examples, ex => ex.GetProperty("kind").GetString() == "code" && + ex.GetProperty("origin").GetString() == "ImportedScript" && ex.GetProperty("text").GetString()!.Contains("Invoke-SampleFunction -Name \"FromScript\"", StringComparison.Ordinal)); } finally @@ -1214,6 +1216,14 @@ public void Generate_PowerShellHelp_GeneratesFallbackExamplesFromBestParameterSe Assert.DoesNotContain(codeExamples, example => example!.Contains("-InputObject", StringComparison.Ordinal)); Assert.Contains(textExamples, example => example!.Contains("parameter set 'ByName'", StringComparison.Ordinal)); Assert.Contains(textExamples, example => example!.Contains("parameter set 'ById'", StringComparison.Ordinal)); + Assert.All( + examples.Where(ex => + { + var kind = ex.GetProperty("kind").GetString(); + return string.Equals(kind, "code", StringComparison.Ordinal) || + string.Equals(kind, "text", StringComparison.Ordinal); + }), + ex => Assert.Equal("GeneratedFallback", ex.GetProperty("origin").GetString())); } finally { @@ -1272,6 +1282,164 @@ public void Generate_ApiDocs_WritesCoverageReport() } } + [Fact] + public void Generate_PowerShellHelp_CoverageDistinguishesAuthoredImportedAndGeneratedExamples() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-apidocs-powershell-example-origins-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var helpPath = Path.Combine(root, "en-US", "Sample.Module-help.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(helpPath)!); + File.WriteAllText(helpPath, + """ + + + + + Get-AuthoredExample + Function + Gets authored example content. + + + + Get-AuthoredExample + + Name + string + + + + + + Name + Name value. + string + + + + + ---------- Example 1: Authored. ---------- + Get-AuthoredExample -Name "Alpha" + + + + + + Invoke-ImportedExample + Function + Gets imported example content. + + + + Invoke-ImportedExample + + Name + string + + + + + + Name + Name value. + string + + + + + + Set-GeneratedExample + Function + Gets generated example content. + + + + Set-GeneratedExample + + Name + string + + + + + + Name + Name value. + string + + + + + """); + + File.WriteAllText(Path.Combine(root, "Sample.Module.psd1"), + """ + @{ + CmdletsToExport = @() + FunctionsToExport = @('Get-AuthoredExample', 'Invoke-ImportedExample', 'Set-GeneratedExample') + AliasesToExport = @() + RootModule = 'Sample.Module.psm1' + } + """); + File.WriteAllText(Path.Combine(root, "Sample.Module.psm1"), + """ + function Get-AuthoredExample { param([string]$Name) } + function Invoke-ImportedExample { param([string]$Name) } + function Set-GeneratedExample { param([string]$Name) } + """); + + var examplesDir = Path.Combine(root, "Examples"); + Directory.CreateDirectory(examplesDir); + File.WriteAllText(Path.Combine(examplesDir, "Invoke-ImportedExample.ps1"), + """ + Invoke-ImportedExample -Name "FromScript" + """); + + var outputPath = Path.Combine(root, "_site", "api", "powershell"); + var options = new WebApiDocsOptions + { + Type = ApiDocsType.PowerShell, + HelpPath = root, + OutputPath = outputPath, + Title = "PowerShell API", + BaseUrl = "/api/powershell", + Format = "json", + CoverageReportPath = "reports/api-coverage.json" + }; + + var result = WebApiDocsGenerator.Generate(options); + using var coverage = JsonDocument.Parse(File.ReadAllText(result.CoveragePath!)); + var powershell = coverage.RootElement.GetProperty("powershell"); + + Assert.Equal(3, powershell.GetProperty("commandCount").GetInt32()); + Assert.Equal(3, powershell.GetProperty("codeExamples").GetProperty("covered").GetInt32()); + Assert.Equal(1, powershell.GetProperty("authoredHelpCodeExamples").GetProperty("covered").GetInt32()); + Assert.Equal(1, powershell.GetProperty("importedScriptCodeExamples").GetProperty("covered").GetInt32()); + Assert.Equal(1, powershell.GetProperty("generatedFallbackCodeExamples").GetProperty("covered").GetInt32()); + Assert.Equal(1, powershell.GetProperty("generatedFallbackOnlyExamples").GetProperty("covered").GetInt32()); + + Assert.Contains( + powershell.GetProperty("commandsUsingAuthoredHelpCodeExamples").EnumerateArray().Select(x => x.GetString()), + value => string.Equals(value, "Get-AuthoredExample", StringComparison.Ordinal)); + Assert.Contains( + powershell.GetProperty("commandsUsingImportedScriptCodeExamples").EnumerateArray().Select(x => x.GetString()), + value => string.Equals(value, "Invoke-ImportedExample", StringComparison.Ordinal)); + Assert.Contains( + powershell.GetProperty("commandsUsingGeneratedFallbackCodeExamples").EnumerateArray().Select(x => x.GetString()), + value => string.Equals(value, "Set-GeneratedExample", StringComparison.Ordinal)); + Assert.Contains( + powershell.GetProperty("commandsUsingGeneratedFallbackOnlyExamples").EnumerateArray().Select(x => x.GetString()), + value => string.Equals(value, "Set-GeneratedExample", StringComparison.Ordinal)); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + [Fact] public void Generate_PowerShellHelp_WarnsWhenCommandsRelyOnlyOnGeneratedFallbackExamples() { @@ -1346,9 +1514,18 @@ public void Generate_PowerShellHelp_WarnsWhenCommandsRelyOnlyOnGeneratedFallback .GetProperty("powershell") .GetProperty("generatedFallbackOnlyExamples"); Assert.Equal(1, generatedFallbackOnly.GetProperty("covered").GetInt32()); + var generatedFallback = coverage.RootElement + .GetProperty("powershell") + .GetProperty("generatedFallbackCodeExamples"); + Assert.Equal(1, generatedFallback.GetProperty("covered").GetInt32()); + Assert.Equal(0, coverage.RootElement.GetProperty("powershell").GetProperty("authoredHelpCodeExamples").GetProperty("covered").GetInt32()); + Assert.Equal(0, coverage.RootElement.GetProperty("powershell").GetProperty("importedScriptCodeExamples").GetProperty("covered").GetInt32()); Assert.Contains( coverage.RootElement.GetProperty("powershell").GetProperty("commandsUsingGeneratedFallbackOnlyExamples").EnumerateArray().Select(x => x.GetString()), value => string.Equals(value, "Invoke-SampleFunction", StringComparison.Ordinal)); + Assert.Contains( + coverage.RootElement.GetProperty("powershell").GetProperty("commandsUsingGeneratedFallbackCodeExamples").EnumerateArray().Select(x => x.GetString()), + value => string.Equals(value, "Invoke-SampleFunction", StringComparison.Ordinal)); } finally { diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.ApiDocs.Models.cs b/PowerForge.Web/Services/WebApiDocsGenerator.ApiDocs.Models.cs index 9e20262c..4a4f02c5 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.ApiDocs.Models.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.ApiDocs.Models.cs @@ -104,6 +104,14 @@ private sealed class ApiExampleModel { public string Kind { get; set; } = "text"; public string Text { get; set; } = string.Empty; + public string? Origin { get; set; } + } + + private static class ApiExampleOrigins + { + public const string AuthoredHelp = "AuthoredHelp"; + public const string ImportedScript = "ImportedScript"; + public const string GeneratedFallback = "GeneratedFallback"; } private sealed class ApiSourceLink diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs b/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs index 5b4172a0..17ca9420 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.Coverage.cs @@ -144,16 +144,15 @@ private static void AppendPowerShellExampleQualityWarnings( var commandCount = commandTypes.Length; var commandsWithSummary = commandTypes.Count(static c => !string.IsNullOrWhiteSpace(c.Summary)); var commandsWithRemarks = commandTypes.Count(static c => !string.IsNullOrWhiteSpace(c.Remarks)); - var commandsWithCodeExamples = commandTypes.Count(static c => c.Examples.Any(static ex => - ex.Kind.Equals("code", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(ex.Text))); + var commandsWithCodeExamples = commandTypes.Count(HasCodeExamples); + var commandsWithAuthoredHelpCodeExamples = commandTypes.Count(static c => HasCodeExamplesFromOrigin(c, ApiExampleOrigins.AuthoredHelp)); + var commandsWithImportedScriptCodeExamples = commandTypes.Count(static c => HasCodeExamplesFromOrigin(c, ApiExampleOrigins.ImportedScript)); + var commandsWithGeneratedFallbackCodeExamples = commandTypes.Count(static c => HasCodeExamplesFromOrigin(c, ApiExampleOrigins.GeneratedFallback)); var commandsWithGeneratedFallbackOnlyExamples = commandTypes.Count(HasOnlyGeneratedPowerShellFallbackExamples); var commandSourceCoverage = AnalyzeSourceCoverage(commandTypes.Select(static c => c.Source)); var commandsMissingExamples = commandTypes - .Where(static c => !c.Examples.Any(static ex => - ex.Kind.Equals("code", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(ex.Text))) + .Where(static c => !HasCodeExamples(c)) .Select(static c => c.FullName) .OrderBy(static c => c, StringComparer.OrdinalIgnoreCase) .Take(100) @@ -164,6 +163,24 @@ private static void AppendPowerShellExampleQualityWarnings( .OrderBy(static c => c, StringComparer.OrdinalIgnoreCase) .Take(100) .ToArray(); + var commandsUsingAuthoredHelpCodeExamples = commandTypes + .Where(static c => HasCodeExamplesFromOrigin(c, ApiExampleOrigins.AuthoredHelp)) + .Select(static c => c.FullName) + .OrderBy(static c => c, StringComparer.OrdinalIgnoreCase) + .Take(100) + .ToArray(); + var commandsUsingImportedScriptCodeExamples = commandTypes + .Where(static c => HasCodeExamplesFromOrigin(c, ApiExampleOrigins.ImportedScript)) + .Select(static c => c.FullName) + .OrderBy(static c => c, StringComparer.OrdinalIgnoreCase) + .Take(100) + .ToArray(); + var commandsUsingGeneratedFallbackCodeExamples = commandTypes + .Where(static c => HasCodeExamplesFromOrigin(c, ApiExampleOrigins.GeneratedFallback)) + .Select(static c => c.FullName) + .OrderBy(static c => c, StringComparer.OrdinalIgnoreCase) + .Take(100) + .ToArray(); var commandParameterCoverage = commandTypes.Select(static c => new { @@ -219,9 +236,15 @@ private static void AppendPowerShellExampleQualityWarnings( ["summary"] = MakeCoverage(commandCount, commandsWithSummary), ["remarks"] = MakeCoverage(commandCount, commandsWithRemarks), ["codeExamples"] = MakeCoverage(commandCount, commandsWithCodeExamples), + ["authoredHelpCodeExamples"] = MakeCoverage(commandCount, commandsWithAuthoredHelpCodeExamples), + ["importedScriptCodeExamples"] = MakeCoverage(commandCount, commandsWithImportedScriptCodeExamples), + ["generatedFallbackCodeExamples"] = MakeCoverage(commandCount, commandsWithGeneratedFallbackCodeExamples), ["generatedFallbackOnlyExamples"] = MakeCoverage(commandCount, commandsWithGeneratedFallbackOnlyExamples), ["parameters"] = MakeCoverage(commandParameterCount, commandParametersWithSummary), ["commandsMissingCodeExamples"] = commandsMissingExamples, + ["commandsUsingAuthoredHelpCodeExamples"] = commandsUsingAuthoredHelpCodeExamples, + ["commandsUsingImportedScriptCodeExamples"] = commandsUsingImportedScriptCodeExamples, + ["commandsUsingGeneratedFallbackCodeExamples"] = commandsUsingGeneratedFallbackCodeExamples, ["commandsUsingGeneratedFallbackOnlyExamples"] = commandsUsingGeneratedFallbackOnlyExamples } }; @@ -269,33 +292,32 @@ private static bool HasOnlyGeneratedPowerShellFallbackExamples(ApiTypeModel type if (type is null || type.Examples.Count == 0) return false; - var hasCode = false; - var hasGeneratedFallbackCode = false; - for (var i = 0; i < type.Examples.Count; i++) - { - var example = type.Examples[i]; - if (example is null || - !example.Kind.Equals("code", StringComparison.OrdinalIgnoreCase) || - string.IsNullOrWhiteSpace(example.Text)) - continue; - - hasCode = true; - var generated = i > 0 && - type.Examples[i - 1] is not null && - type.Examples[i - 1].Kind.Equals("text", StringComparison.OrdinalIgnoreCase) && - IsGeneratedPowerShellFallbackLabel(type.Examples[i - 1].Text); - if (!generated) - return false; + var codeExamples = type.Examples.Where(IsCodeExample).ToArray(); + return codeExamples.Length > 0 && + codeExamples.All(static example => + string.Equals(example.Origin, ApiExampleOrigins.GeneratedFallback, StringComparison.OrdinalIgnoreCase)); + } - hasGeneratedFallbackCode = true; - } + private static bool HasCodeExamples(ApiTypeModel type) + { + return type is not null && type.Examples.Any(IsCodeExample); + } - return hasCode && hasGeneratedFallbackCode; + private static bool HasCodeExamplesFromOrigin(ApiTypeModel type, string origin) + { + return type is not null && + !string.IsNullOrWhiteSpace(origin) && + type.Examples.Any(example => + IsCodeExample(example) && + string.Equals(example.Origin, origin, StringComparison.OrdinalIgnoreCase)); } - private static bool IsGeneratedPowerShellFallbackLabel(string? text) - => !string.IsNullOrWhiteSpace(text) && - text.TrimStart().StartsWith("Generated fallback example", StringComparison.OrdinalIgnoreCase); + private static bool IsCodeExample(ApiExampleModel example) + { + return example is not null && + example.Kind.Equals("code", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(example.Text); + } private static Dictionary BuildSourceCoveragePayload(int total, SourceCoverageStats coverage) { diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs b/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs index 70069f98..97f6a5e5 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.Parse.PowerShellExamples.cs @@ -47,7 +47,8 @@ private static void AppendPowerShellFallbackExamples( type.Examples.Add(new ApiExampleModel { Kind = "code", - Text = snippet + Text = snippet, + Origin = ApiExampleOrigins.ImportedScript }); } continue; @@ -58,12 +59,14 @@ private static void AppendPowerShellFallbackExamples( type.Examples.Add(new ApiExampleModel { Kind = "text", - Text = fallback.Label + Text = fallback.Label, + Origin = ApiExampleOrigins.GeneratedFallback }); type.Examples.Add(new ApiExampleModel { Kind = "code", - Text = fallback.Code + Text = fallback.Code, + Origin = ApiExampleOrigins.GeneratedFallback }); } } diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.Parse.cs b/PowerForge.Web/Services/WebApiDocsGenerator.Parse.cs index 8210e567..760d7165 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.Parse.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.Parse.cs @@ -865,7 +865,8 @@ private static void AppendPowerShellExamples( type.Examples.Add(new ApiExampleModel { Kind = "heading", - Text = title + Text = title, + Origin = ApiExampleOrigins.AuthoredHelp }); } @@ -874,7 +875,8 @@ private static void AppendPowerShellExamples( type.Examples.Add(new ApiExampleModel { Kind = "text", - Text = introduction + Text = introduction, + Origin = ApiExampleOrigins.AuthoredHelp }); } @@ -888,7 +890,8 @@ private static void AppendPowerShellExamples( type.Examples.Add(new ApiExampleModel { Kind = "code", - Text = code + Text = code, + Origin = ApiExampleOrigins.AuthoredHelp }); } @@ -897,7 +900,8 @@ private static void AppendPowerShellExamples( type.Examples.Add(new ApiExampleModel { Kind = "text", - Text = remark + Text = remark, + Origin = ApiExampleOrigins.AuthoredHelp }); } @@ -908,7 +912,8 @@ private static void AppendPowerShellExamples( type.Examples.Add(new ApiExampleModel { Kind = "text", - Text = remark + Text = remark, + Origin = ApiExampleOrigins.AuthoredHelp }); } } diff --git a/PowerForge.Web/Services/WebApiDocsGenerator.cs b/PowerForge.Web/Services/WebApiDocsGenerator.cs index 3d10931e..e6f0a57d 100644 --- a/PowerForge.Web/Services/WebApiDocsGenerator.cs +++ b/PowerForge.Web/Services/WebApiDocsGenerator.cs @@ -465,11 +465,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) ["name"] = tp.Name, ["summary"] = tp.Summary }).ToList(), - ["examples"] = type.Examples.Select(ex => new Dictionary - { - ["kind"] = ex.Kind, - ["text"] = ex.Text - }).ToList(), + ["examples"] = SerializeExamples(type.Examples), ["seeAlso"] = type.SeeAlso, ["methods"] = type.Methods.Select(m => new Dictionary { @@ -498,11 +494,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) ["name"] = tp.Name, ["summary"] = tp.Summary }).ToList(), - ["examples"] = m.Examples.Select(ex => new Dictionary - { - ["kind"] = ex.Kind, - ["text"] = ex.Text - }).ToList(), + ["examples"] = SerializeExamples(m.Examples), ["exceptions"] = m.Exceptions.Select(ex => new Dictionary { ["type"] = ex.Type, @@ -546,11 +538,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) ["name"] = tp.Name, ["summary"] = tp.Summary }).ToList(), - ["examples"] = m.Examples.Select(ex => new Dictionary - { - ["kind"] = ex.Kind, - ["text"] = ex.Text - }).ToList(), + ["examples"] = SerializeExamples(m.Examples), ["exceptions"] = m.Exceptions.Select(ex => new Dictionary { ["type"] = ex.Type, @@ -585,11 +573,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) ["access"] = p.Access, ["modifiers"] = p.Modifiers, ["valueSummary"] = p.ValueSummary, - ["examples"] = p.Examples.Select(ex => new Dictionary - { - ["kind"] = ex.Kind, - ["text"] = ex.Text - }).ToList(), + ["examples"] = SerializeExamples(p.Examples), ["exceptions"] = p.Exceptions.Select(ex => new Dictionary { ["type"] = ex.Type, @@ -613,11 +597,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) ["modifiers"] = f.Modifiers, ["value"] = f.Value, ["valueSummary"] = f.ValueSummary, - ["examples"] = f.Examples.Select(ex => new Dictionary - { - ["kind"] = ex.Kind, - ["text"] = ex.Text - }).ToList(), + ["examples"] = SerializeExamples(f.Examples), ["exceptions"] = f.Exceptions.Select(ex => new Dictionary { ["type"] = ex.Type, @@ -639,11 +619,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) ["isStatic"] = e.IsStatic, ["access"] = e.Access, ["modifiers"] = e.Modifiers, - ["examples"] = e.Examples.Select(ex => new Dictionary - { - ["kind"] = ex.Kind, - ["text"] = ex.Text - }).ToList(), + ["examples"] = SerializeExamples(e.Examples), ["exceptions"] = e.Exceptions.Select(ex => new Dictionary { ["type"] = ex.Type, @@ -676,11 +652,7 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) ["name"] = tp.Name, ["summary"] = tp.Summary }).ToList(), - ["examples"] = m.Examples.Select(ex => new Dictionary - { - ["kind"] = ex.Kind, - ["text"] = ex.Text - }).ToList(), + ["examples"] = SerializeExamples(m.Examples), ["exceptions"] = m.Exceptions.Select(ex => new Dictionary { ["type"] = ex.Type, @@ -737,6 +709,32 @@ public static WebApiDocsResult Generate(WebApiDocsOptions options) }; } + private static List> SerializeExamples(IEnumerable examples) + { + if (examples is null) + return new List>(); + + var items = new List>(); + foreach (var example in examples) + { + if (example is null) + continue; + + var payload = new Dictionary + { + ["kind"] = example.Kind, + ["text"] = example.Text + }; + + if (!string.IsNullOrWhiteSpace(example.Origin)) + payload["origin"] = example.Origin; + + items.Add(payload); + } + + return items; + } + private static string NormalizeWarningCode(string warning) { if (string.IsNullOrWhiteSpace(warning)) From 12a08a23a324bbfca7f3f643833b91d60a548812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 09:50:15 +0100 Subject: [PATCH 08/25] Add API docs thresholds for PowerShell example origins --- Docs/PowerForge.Web.ApiDocs.md | 1 + Docs/PowerForge.Web.Pipeline.md | 2 +- .../WebPipelineRunnerApiDocsPreflightTests.cs | 53 +++++++++++++++++++ .../WebPipelineRunner.Tasks.Content.cs | 2 + 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/Docs/PowerForge.Web.ApiDocs.md b/Docs/PowerForge.Web.ApiDocs.md index a1d64210..51e50ede 100644 --- a/Docs/PowerForge.Web.ApiDocs.md +++ b/Docs/PowerForge.Web.ApiDocs.md @@ -369,5 +369,6 @@ Notes: - `ImportedScript` for examples imported from `psExamplesPath` / `Examples/` - `GeneratedFallback` for auto-generated fallback examples - Coverage reports now split PowerShell example coverage into `authoredHelpCodeExamples`, `importedScriptCodeExamples`, and `generatedFallbackCodeExamples`, alongside the existing `generatedFallbackOnlyExamples` guardrail. +- Pipeline coverage thresholds can also gate provenance-specific example quality via `minPowerShellAuthoredHelpCodeExamplesPercent` and `minPowerShellImportedScriptCodeExamplesPercent`. - Pipeline coverage thresholds can now gate that metric via `maxPowerShellGeneratedFallbackOnlyExamplePercent` or `maxPowerShellGeneratedFallbackOnlyExampleCount`. - In pipeline `apidocs` steps, you can gate quality with coverage thresholds (for example `minPowerShellCodeExamplesPercent`, `minMemberSummaryPercent`) and enforce via `failOnCoverage:true`. diff --git a/Docs/PowerForge.Web.Pipeline.md b/Docs/PowerForge.Web.Pipeline.md index a46518b1..5010b34e 100644 --- a/Docs/PowerForge.Web.Pipeline.md +++ b/Docs/PowerForge.Web.Pipeline.md @@ -310,7 +310,7 @@ Notes: - `generateMemberXrefs`: include member/parameter xref entries in the map (default: `true`) - `memberXrefKinds`: optional member-kind filter (`constructors,methods,properties,fields,events,extensions,parameters`) - `memberXrefMaxPerType`: optional cap for member xref entries per type/command (`0` = unlimited) - - coverage thresholds (0-100): `minTypeSummaryPercent`, `minTypeRemarksPercent`, `minTypeCodeExamplesPercent`, `minMemberSummaryPercent`, `minMemberCodeExamplesPercent`, `minPowerShellSummaryPercent`, `minPowerShellRemarksPercent`, `minPowerShellCodeExamplesPercent`, `minPowerShellParameterSummaryPercent` + - coverage thresholds (0-100): `minTypeSummaryPercent`, `minTypeRemarksPercent`, `minTypeCodeExamplesPercent`, `minMemberSummaryPercent`, `minMemberCodeExamplesPercent`, `minPowerShellSummaryPercent`, `minPowerShellRemarksPercent`, `minPowerShellCodeExamplesPercent`, `minPowerShellAuthoredHelpCodeExamplesPercent`, `minPowerShellImportedScriptCodeExamplesPercent`, `minPowerShellParameterSummaryPercent` - source coverage thresholds (0-100): `minTypeSourcePathPercent`, `minTypeSourceUrlPercent`, `minMemberSourcePathPercent`, `minMemberSourceUrlPercent`, `minPowerShellSourcePathPercent`, `minPowerShellSourceUrlPercent` - PowerShell example quality thresholds: `maxPowerShellGeneratedFallbackOnlyExamplePercent` (0-100), `maxPowerShellGeneratedFallbackOnlyExampleCount` (>=0) - source quality max-count thresholds (>=0): `maxTypeSourceInvalidUrlCount`, `maxMemberSourceInvalidUrlCount`, `maxPowerShellSourceInvalidUrlCount`, `maxTypeSourceUnresolvedTemplateCount`, `maxMemberSourceUnresolvedTemplateCount`, `maxPowerShellSourceUnresolvedTemplateCount`, `maxTypeSourceRepoMismatchHints`, `maxMemberSourceRepoMismatchHints`, `maxPowerShellSourceRepoMismatchHints` diff --git a/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs b/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs index 191e83f0..674a3672 100644 --- a/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs +++ b/PowerForge.Tests/WebPipelineRunnerApiDocsPreflightTests.cs @@ -326,6 +326,59 @@ public void RunPipeline_ApiDocs_FailsWhenGeneratedOnlyPowerShellExamplesExceedTh } } + [Fact] + public void RunPipeline_ApiDocs_FailsWhenAuthoredPowerShellExampleCoverageFallsBelowThreshold() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-pipeline-apidocs-powershell-authored-threshold-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var helpPath = Path.Combine(root, "Sample.Module-help.xml"); + File.WriteAllText(helpPath, BuildMinimalPowerShellHelpWithoutExamples()); + File.WriteAllText(Path.Combine(root, "Sample.Module.psd1"), + """ + @{ + CmdletsToExport = @() + FunctionsToExport = @('Invoke-SampleFunction') + AliasesToExport = @() + RootModule = 'Sample.Module.psm1' + } + """); + File.WriteAllText(Path.Combine(root, "Sample.Module.psm1"), "function Invoke-SampleFunction { param([string]$Name) }"); + + var pipelinePath = Path.Combine(root, "pipeline.json"); + File.WriteAllText(pipelinePath, + """ + { + "steps": [ + { + "task": "apidocs", + "type": "PowerShell", + "help": "./Sample.Module-help.xml", + "out": "./_site/api", + "format": "json", + "failOnCoverage": true, + "minPowerShellAuthoredHelpCodeExamplesPercent": 100 + } + ] + } + """); + + var result = WebPipelineRunner.RunPipeline(pipelinePath, logger: null); + + Assert.False(result.Success); + Assert.Single(result.Steps); + Assert.False(result.Steps[0].Success); + Assert.Contains("PowerShell authored-help code examples coverage", result.Steps[0].Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("below required 100%", result.Steps[0].Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDeleteDirectory(root); + } + } + private static string BuildMinimalXml() { return diff --git a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs index 288490ad..a1ace9b0 100644 --- a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs +++ b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.Content.cs @@ -691,6 +691,8 @@ private static List GetApiDocsCoverageThresholds(JsonE AddCoverageMinPercentThreshold(thresholds, step, "minPowerShellSummaryPercent", "min-powershell-summary-percent", "powershell.summary.percent", "PowerShell command summary coverage", powerShellCommandMetric: true); AddCoverageMinPercentThreshold(thresholds, step, "minPowerShellRemarksPercent", "min-powershell-remarks-percent", "powershell.remarks.percent", "PowerShell command remarks coverage", powerShellCommandMetric: true); AddCoverageMinPercentThreshold(thresholds, step, "minPowerShellCodeExamplesPercent", "min-powershell-code-examples-percent", "powershell.codeExamples.percent", "PowerShell command code examples coverage", powerShellCommandMetric: true); + AddCoverageMinPercentThreshold(thresholds, step, "minPowerShellAuthoredHelpCodeExamplesPercent", "min-powershell-authored-help-code-examples-percent", "powershell.authoredHelpCodeExamples.percent", "PowerShell authored-help code examples coverage", powerShellCommandMetric: true); + AddCoverageMinPercentThreshold(thresholds, step, "minPowerShellImportedScriptCodeExamplesPercent", "min-powershell-imported-script-code-examples-percent", "powershell.importedScriptCodeExamples.percent", "PowerShell imported-script code examples coverage", powerShellCommandMetric: true); AddCoverageMinPercentThreshold(thresholds, step, "minPowerShellParameterSummaryPercent", "min-powershell-parameter-summary-percent", "powershell.parameters.percent", "PowerShell parameter summary coverage", powerShellCommandMetric: true); AddCoverageMinPercentThreshold(thresholds, step, "minTypeSourcePathPercent", "min-type-source-path-percent", "source.types.path.percent", "Type source path coverage"); AddCoverageMinPercentThreshold(thresholds, step, "minTypeSourceUrlPercent", "min-type-source-url-percent", "source.types.url.percent", "Type source URL coverage"); From bc3af3c4683ffa2f4e6991d128a204be5f4842e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 25 Mar 2026 10:51:08 +0100 Subject: [PATCH 09/25] Add example media support to API docs --- Docs/PowerForge.Web.ApiDocs.md | 22 +++++ .../WebApiDocsGeneratorSourceAndCssTests.cs | 69 ++++++++++++++ PowerForge.Web/Assets/ApiDocs/fallback.css | 12 +++ .../WebApiDocsGenerator.ApiDocs.Models.cs | 14 +++ .../WebApiDocsGenerator.Html.TypeDetail.cs | 68 ++++++++++++++ ...bApiDocsGenerator.Reflection.Attributes.cs | 21 ++++- .../WebApiDocsGenerator.XmlAndHtml.cs | 90 +++++++++++++++++++ .../Services/WebApiDocsGenerator.cs | 15 ++++ 8 files changed, 310 insertions(+), 1 deletion(-) diff --git a/Docs/PowerForge.Web.ApiDocs.md b/Docs/PowerForge.Web.ApiDocs.md index 51e50ede..51d97ddc 100644 --- a/Docs/PowerForge.Web.ApiDocs.md +++ b/Docs/PowerForge.Web.ApiDocs.md @@ -203,6 +203,10 @@ Member layout: - `.derived-list` – derived types list - `.type-parameters` – type parameter section - `.type-examples` – example section +- `.example-media` – example media figure wrapper +- `.example-media-frame` – media surface (image/video/link frame) +- `.example-media-link` – terminal/download link for non-inline example media +- `.example-media-caption` – media caption - `.type-see-also` – see also section ## JavaScript expectations @@ -261,6 +265,24 @@ in the pipeline or pass `--documented-only` to the CLI. `` and `` tags are converted into links when the referenced type exists in the generated API docs. +XML-doc `` blocks can also include optional media nodes for richer API examples: + +```xml + + Sample.Run(); + Rendered output + + +``` + +Supported media nodes: +- `` / `` / `` +- `