From 2c3704e03f03ac67092959618beb17bcfa923150 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 05:50:42 +0000 Subject: [PATCH 1/3] Initial plan From eb2831c78607638ce9f1cdca62cc54245095783a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 06:07:54 +0000 Subject: [PATCH 2/3] Add markdown output format for CI environments Co-authored-by: gfs <98900+gfs@users.noreply.github.com> --- AppInspector.CLI/CLICmdOptions.cs | 2 +- AppInspector.CLI/Program.cs | 3 +- .../Writers/AnalyzeMarkdownWriter.cs | 314 ++++++++++++++++++ AppInspector.CLI/Writers/WriterFactory.cs | 1 + 4 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 AppInspector.CLI/Writers/AnalyzeMarkdownWriter.cs diff --git a/AppInspector.CLI/CLICmdOptions.cs b/AppInspector.CLI/CLICmdOptions.cs index e9269cc4..6d493d3e 100644 --- a/AppInspector.CLI/CLICmdOptions.cs +++ b/AppInspector.CLI/CLICmdOptions.cs @@ -130,7 +130,7 @@ public record CLIAnalyzeCmdOptions : CLIAnalysisSharedCommandOptions Separator = ',')] public IEnumerable SourcePath { get; set; } = Array.Empty(); - [Option('f', "output-file-format", Required = false, HelpText = "Output format [html|json|text]", Default = "html")] + [Option('f', "output-file-format", Required = false, HelpText = "Output format [html|json|text|markdown|sarif]", Default = "html")] public new string OutputFileFormat { get; set; } = "html"; [Option('e', "text-format", Required = false, HelpText = "Match text format specifiers", diff --git a/AppInspector.CLI/Program.cs b/AppInspector.CLI/Program.cs index 99af21a2..3888e40a 100644 --- a/AppInspector.CLI/Program.cs +++ b/AppInspector.CLI/Program.cs @@ -142,7 +142,8 @@ private static bool CommonOutputChecks(CLICommandOptions options) "html", "text", "json", - "sarif" + "sarif", + "markdown" }; var logger = loggerFactory.CreateLogger("Program"); string[] checkFormats; diff --git a/AppInspector.CLI/Writers/AnalyzeMarkdownWriter.cs b/AppInspector.CLI/Writers/AnalyzeMarkdownWriter.cs new file mode 100644 index 00000000..c874b519 --- /dev/null +++ b/AppInspector.CLI/Writers/AnalyzeMarkdownWriter.cs @@ -0,0 +1,314 @@ +// Copyright (C) Microsoft. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.ApplicationInspector.Commands; +using Microsoft.ApplicationInspector.RulesEngine; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.ApplicationInspector.CLI; + +/// +/// Writes analysis results in Markdown format, suitable for CI environments. +/// Provides a concise summary of key features and findings. +/// +public class AnalyzeMarkdownWriter : CommandResultsWriter +{ + private readonly ILogger _logger; + + public AnalyzeMarkdownWriter(TextWriter textWriter, ILoggerFactory? loggerFactory = null) : base(textWriter) + { + _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; + } + + public override void WriteResults(Result result, CLICommandOptions commandOptions, bool autoClose = true) + { + var analyzeResult = (AnalyzeResult)result; + if (TextWriter is null) + { + throw new ArgumentNullException(nameof(TextWriter)); + } + + WriteMarkdownReport(analyzeResult); + + if (autoClose) + { + FlushAndClose(); + } + } + + private void WriteMarkdownReport(AnalyzeResult analyzeResult) + { + var metadata = analyzeResult.Metadata; + + // Title + TextWriter.WriteLine("# Application Inspector Analysis Report"); + TextWriter.WriteLine(); + + // Summary Section + TextWriter.WriteLine("## Summary"); + TextWriter.WriteLine(); + WriteProjectInfo(metadata); + TextWriter.WriteLine(); + + // Key Statistics + TextWriter.WriteLine("## Key Statistics"); + TextWriter.WriteLine(); + WriteStatistics(metadata); + TextWriter.WriteLine(); + + // Key Features Detected + TextWriter.WriteLine("## Key Features Detected"); + TextWriter.WriteLine(); + WriteKeyFeatures(metadata); + TextWriter.WriteLine(); + + // Detected Technologies + if (metadata.Languages?.Any() == true || metadata.AppTypes?.Any() == true) + { + TextWriter.WriteLine("## Detected Technologies"); + TextWriter.WriteLine(); + WriteDetectedTechnologies(metadata); + TextWriter.WriteLine(); + } + + // Target Platforms + if (HasTargetPlatforms(metadata)) + { + TextWriter.WriteLine("## Target Platforms"); + TextWriter.WriteLine(); + WriteTargetPlatforms(metadata); + TextWriter.WriteLine(); + } + + // Dependencies + if (metadata.UniqueDependencies?.Any() == true) + { + TextWriter.WriteLine("## Dependencies"); + TextWriter.WriteLine(); + WriteDependencies(metadata); + TextWriter.WriteLine(); + } + + // Tag Counters + if (metadata.TagCounters?.Any() == true) + { + TextWriter.WriteLine("## Detailed Tag Counters"); + TextWriter.WriteLine(); + WriteTagCounters(metadata); + } + } + + private void WriteProjectInfo(MetaData metadata) + { + TextWriter.WriteLine($"- **Application Name**: {metadata.ApplicationName ?? "N/A"}"); + if (!string.IsNullOrEmpty(metadata.SourceVersion)) + { + TextWriter.WriteLine($"- **Version**: {metadata.SourceVersion}"); + } + TextWriter.WriteLine($"- **Source Path**: `{metadata.SourcePath ?? "N/A"}`"); + if (!string.IsNullOrEmpty(metadata.Description)) + { + TextWriter.WriteLine($"- **Description**: {metadata.Description}"); + } + if (!string.IsNullOrEmpty(metadata.Authors)) + { + TextWriter.WriteLine($"- **Authors**: {metadata.Authors}"); + } + TextWriter.WriteLine($"- **Date Scanned**: {metadata.DateScanned ?? "N/A"}"); + if (!string.IsNullOrEmpty(metadata.LastUpdated) && metadata.LastUpdated != DateTime.MinValue.ToString()) + { + TextWriter.WriteLine($"- **Last Updated**: {metadata.LastUpdated}"); + } + } + + private void WriteStatistics(MetaData metadata) + { + TextWriter.WriteLine("| Metric | Count |"); + TextWriter.WriteLine("|--------|-------|"); + TextWriter.WriteLine($"| Total Files | {metadata.TotalFiles} |"); + TextWriter.WriteLine($"| Files Analyzed | {metadata.FilesAnalyzed} |"); + TextWriter.WriteLine($"| Files Skipped | {metadata.FilesSkipped} |"); + if (metadata.FilesTimedOut > 0) + { + TextWriter.WriteLine($"| Files Timed Out | {metadata.FilesTimedOut} |"); + } + TextWriter.WriteLine($"| Files with Matches | {metadata.FilesAffected} |"); + TextWriter.WriteLine($"| Total Matches | {metadata.TotalMatchesCount} |"); + TextWriter.WriteLine($"| Unique Matches | {metadata.UniqueMatchesCount} |"); + TextWriter.WriteLine($"| Unique Tags | {metadata.UniqueTags.Count} |"); + } + + private void WriteKeyFeatures(MetaData metadata) + { + if (metadata.UniqueTags?.Any() != true) + { + TextWriter.WriteLine("_No unique features detected._"); + return; + } + + // Group tags by category for better organization + var tagsByCategory = metadata.UniqueTags + .GroupBy(tag => tag.Split('.').FirstOrDefault() ?? "Other") + .OrderBy(g => g.Key); + + foreach (var category in tagsByCategory) + { + TextWriter.WriteLine($"### {category.Key}"); + TextWriter.WriteLine(); + foreach (var tag in category.OrderBy(t => t)) + { + TextWriter.WriteLine($"- `{tag}`"); + } + TextWriter.WriteLine(); + } + } + + private void WriteDetectedTechnologies(MetaData metadata) + { + if (metadata.Languages?.Any() == true) + { + TextWriter.WriteLine("### Languages"); + TextWriter.WriteLine(); + foreach (var lang in metadata.Languages.OrderByDescending(l => l.Value)) + { + TextWriter.WriteLine($"- **{lang.Key}**: {lang.Value} file(s)"); + } + TextWriter.WriteLine(); + } + + if (metadata.AppTypes?.Any() == true) + { + TextWriter.WriteLine("### Application Types"); + TextWriter.WriteLine(); + foreach (var appType in metadata.AppTypes.OrderBy(a => a)) + { + TextWriter.WriteLine($"- {appType}"); + } + TextWriter.WriteLine(); + } + + if (metadata.PackageTypes?.Any() == true) + { + TextWriter.WriteLine("### Package Types"); + TextWriter.WriteLine(); + foreach (var packageType in metadata.PackageTypes.OrderBy(p => p)) + { + TextWriter.WriteLine($"- {packageType}"); + } + TextWriter.WriteLine(); + } + + if (metadata.FileExtensions?.Any() == true) + { + TextWriter.WriteLine("### File Extensions"); + TextWriter.WriteLine(); + var extensions = string.Join(", ", metadata.FileExtensions.OrderBy(e => e).Select(e => $"`{e}`")); + TextWriter.WriteLine(extensions); + } + } + + private bool HasTargetPlatforms(MetaData metadata) + { + return (metadata.OSTargets?.Any() == true) || + (metadata.CPUTargets?.Any() == true) || + (metadata.CloudTargets?.Any() == true) || + (metadata.Outputs?.Any() == true); + } + + private void WriteTargetPlatforms(MetaData metadata) + { + if (metadata.Outputs?.Any() == true) + { + TextWriter.WriteLine("### Output Types"); + TextWriter.WriteLine(); + foreach (var output in metadata.Outputs.OrderBy(o => o)) + { + TextWriter.WriteLine($"- {output}"); + } + TextWriter.WriteLine(); + } + + if (metadata.OSTargets?.Any() == true) + { + TextWriter.WriteLine("### Operating Systems"); + TextWriter.WriteLine(); + foreach (var os in metadata.OSTargets.OrderBy(o => o)) + { + TextWriter.WriteLine($"- {os}"); + } + TextWriter.WriteLine(); + } + + if (metadata.CPUTargets?.Any() == true) + { + TextWriter.WriteLine("### CPU Architectures"); + TextWriter.WriteLine(); + foreach (var cpu in metadata.CPUTargets.OrderBy(c => c)) + { + TextWriter.WriteLine($"- {cpu}"); + } + TextWriter.WriteLine(); + } + + if (metadata.CloudTargets?.Any() == true) + { + TextWriter.WriteLine("### Cloud Platforms"); + TextWriter.WriteLine(); + foreach (var cloud in metadata.CloudTargets.OrderBy(c => c)) + { + TextWriter.WriteLine($"- {cloud}"); + } + } + } + + private void WriteDependencies(MetaData metadata) + { + if (metadata.UniqueDependencies?.Any() != true) + { + return; + } + + var deps = metadata.UniqueDependencies.OrderBy(d => d).ToList(); + + if (deps.Count <= 20) + { + // Show all dependencies if 20 or fewer + foreach (var dep in deps) + { + TextWriter.WriteLine($"- `{dep}`"); + } + } + else + { + // Show first 20 and indicate there are more + foreach (var dep in deps.Take(20)) + { + TextWriter.WriteLine($"- `{dep}`"); + } + TextWriter.WriteLine(); + TextWriter.WriteLine($"_... and {deps.Count - 20} more_"); + } + } + + private void WriteTagCounters(MetaData metadata) + { + if (metadata.TagCounters?.Any() != true) + { + return; + } + + TextWriter.WriteLine("| Tag | Count |"); + TextWriter.WriteLine("|-----|-------|"); + + foreach (var counter in metadata.TagCounters.OrderByDescending(c => c.Count).ThenBy(c => c.Tag)) + { + TextWriter.WriteLine($"| `{counter.Tag}` | {counter.Count} |"); + } + } +} diff --git a/AppInspector.CLI/Writers/WriterFactory.cs b/AppInspector.CLI/Writers/WriterFactory.cs index 5c6a1241..b85bae26 100644 --- a/AppInspector.CLI/Writers/WriterFactory.cs +++ b/AppInspector.CLI/Writers/WriterFactory.cs @@ -57,6 +57,7 @@ private CommandResultsWriter GetAnalyzeWriter(CLIAnalyzeCmdOptions options) "text" => new AnalyzeTextWriter(streamWriter, options.TextOutputFormat, _loggerFactory), "html" => new AnalyzeHtmlWriter(streamWriter, _loggerFactory), "sarif" => new AnalyzeSarifWriter(streamWriter, _loggerFactory), + "markdown" => new AnalyzeMarkdownWriter(streamWriter, _loggerFactory), _ => throw new OpException(MsgHelp.FormatString(MsgHelp.ID.CMD_INVALID_ARG_VALUE, "-f")) }; } From fc77296537f48d680e3e1f846373c77e141894dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 06:11:40 +0000 Subject: [PATCH 3/3] Add test for markdown writer and update README Co-authored-by: gfs <98900+gfs@users.noreply.github.com> --- .../Commands/TestMarkdownWriter.cs | 65 +++++++++++++++++++ README.md | 8 +++ 2 files changed, 73 insertions(+) create mode 100644 AppInspector.Tests/Commands/TestMarkdownWriter.cs diff --git a/AppInspector.Tests/Commands/TestMarkdownWriter.cs b/AppInspector.Tests/Commands/TestMarkdownWriter.cs new file mode 100644 index 00000000..dc076458 --- /dev/null +++ b/AppInspector.Tests/Commands/TestMarkdownWriter.cs @@ -0,0 +1,65 @@ +using System; +using Xunit; +using System.IO; +using Microsoft.ApplicationInspector.Commands; +using Microsoft.ApplicationInspector.CLI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AppInspector.Tests.Commands +{ + public class TestMarkdownWriter + { + private readonly ILoggerFactory factory = new NullLoggerFactory(); + + [Fact] + public void MarkdownWriterTest() + { + // Arrange + var testFilePath = Path.Combine("TestData", "TestAnalyzeCmd", "Samples", "FourWindowsOneLinux.js"); + var testRulesPath = Path.Combine("TestData", "TestAnalyzeCmd", "Rules", "FindWindows.json"); + var outputPath = Path.Combine(Path.GetTempPath(), $"test_markdown_{Guid.NewGuid()}.md"); + + try + { + // Run analyze command + AnalyzeOptions options = new() + { + SourcePath = new[] { testFilePath }, + CustomRulesPath = testRulesPath, + IgnoreDefaultRules = true + }; + + AnalyzeCommand command = new(options, factory); + var result = command.GetResult(); + + Assert.Equal(AnalyzeResult.ExitCode.Success, result.ResultCode); + + // Write markdown + using var streamWriter = new StreamWriter(outputPath); + var cliOptions = new CLIAnalyzeCmdOptions { OutputFilePath = outputPath, OutputFileFormat = "markdown" }; + var writer = new AnalyzeMarkdownWriter(streamWriter, factory); + writer.WriteResults(result, cliOptions); + + // Verify output + Assert.True(File.Exists(outputPath)); + var content = File.ReadAllText(outputPath); + + Assert.Contains("# Application Inspector Analysis Report", content); + Assert.Contains("## Summary", content); + Assert.Contains("## Key Statistics", content); + Assert.Contains("## Key Features Detected", content); + Assert.Contains("| Metric | Count |", content); + Assert.Contains("| Total Files |", content); + Assert.Contains("| Files Analyzed |", content); + } + finally + { + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + } + } + } +} diff --git a/README.md b/README.md index 51eec38a..3bd2d15a 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,14 @@ appinspector analyze -s path/to/files appinspector analyze -s path/to/files -f sarif -o output.sarif ``` +#### Output Markdown + +This will create a markdown output suitable for CI environments with a summary of key features. + +``` +appinspector analyze -s path/to/files -f markdown -o report.md +``` + #### Excluding Files using Globs This will create a json output named data.json of the analysis in the current directory, excluding all files in `test`