From dda9f03b36fcec8148ba58e84bbcea2754b8590d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:32:16 +0000 Subject: [PATCH 1/6] Initial plan From 841ada2fbf04bf173a652d11682d63c098cf02c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:40:54 +0000 Subject: [PATCH 2/6] Add business linter infrastructure and NoLoadInForeach rule Co-authored-by: joaoopereira <3718704+joaoopereira@users.noreply.github.com> --- .../Abstractions/ILintLogger.cs | 7 + .../BusinessLinter/Abstractions/ILintRule.cs | 11 + .../Abstractions/IRuleFactory.cs | 6 + .../Extensions/ServiceCollectionExtensions.cs | 20 ++ .../business/BusinessLinter/LintLogger.cs | 20 ++ .../business/BusinessLinter/RuleFactory.cs | 23 ++ .../BusinessLinter/Rules/BaseLintRule.cs | 20 ++ .../Rules/NoLoadInForeachRule.cs | 67 +++++ .../business/BusinessLinter/SolutionLinter.cs | 80 ++++++ .../business/BusinessLinter/SolutionLoader.cs | 41 +++ .../Commands/build/business/LintCommand.cs | 71 ++++++ tests/Specs/BusinessLinter.cs | 233 ++++++++++++++++++ 12 files changed, 599 insertions(+) create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/Abstractions/ILintLogger.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/Abstractions/ILintRule.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/Abstractions/IRuleFactory.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/Extensions/ServiceCollectionExtensions.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/Rules/BaseLintRule.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/Rules/NoLoadInForeachRule.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/SolutionLinter.cs create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/SolutionLoader.cs create mode 100644 cmf-cli/Commands/build/business/LintCommand.cs create mode 100644 tests/Specs/BusinessLinter.cs diff --git a/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/ILintLogger.cs b/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/ILintLogger.cs new file mode 100644 index 000000000..1dc8fdd32 --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/ILintLogger.cs @@ -0,0 +1,7 @@ +namespace Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; + +internal interface ILintLogger +{ + void Warning(string message); + void Error(string message); +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/ILintRule.cs b/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/ILintRule.cs new file mode 100644 index 000000000..010545c12 --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/ILintRule.cs @@ -0,0 +1,11 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; + +internal interface ILintRule +{ + string RuleName { get; } + string RuleDescription { get; } + bool IsEnabled { get; set; } + void Analyze(MethodDeclarationSyntax methodNode, string filePath, string className); +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/IRuleFactory.cs b/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/IRuleFactory.cs new file mode 100644 index 000000000..ce03b5d8e --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/IRuleFactory.cs @@ -0,0 +1,6 @@ +namespace Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; + +internal interface IRuleFactory +{ + IEnumerable CreateEnabledRules(); +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/Extensions/ServiceCollectionExtensions.cs b/cmf-cli/Commands/build/business/BusinessLinter/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..a89d82bbb --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; +using Cmf.CLI.Commands.build.business.BusinessLinter.Rules; +using Microsoft.Extensions.DependencyInjection; + +namespace Cmf.CLI.Commands.build.business.BusinessLinter.Extensions; + +internal static class ServiceCollectionExtensions +{ + public static void AddLinterServices(this ServiceCollection services) + { + // Add factory + services.AddSingleton(); + + // Add logger + services.AddSingleton(); + + // Add rules + services.AddTransient(); + } +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs b/cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs new file mode 100644 index 000000000..556d727e3 --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs @@ -0,0 +1,20 @@ +using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; + +namespace Cmf.CLI.Commands.build.business.BusinessLinter; + +internal class LintLogger : ILintLogger +{ + public void Warning(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"Warning: {message}"); + Console.ResetColor(); + } + + public void Error(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {message}"); + Console.ResetColor(); + } +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs b/cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs new file mode 100644 index 000000000..2cb74a8a0 --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs @@ -0,0 +1,23 @@ +using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Cmf.CLI.Commands.build.business.BusinessLinter; + +internal class RuleFactory : IRuleFactory +{ + private readonly IServiceProvider _serviceProvider; + + public RuleFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public IEnumerable CreateEnabledRules() + { + // Get all registered rules from DI container + var rules = _serviceProvider.GetServices(); + + // Return only enabled rules + return rules.Where(r => r.IsEnabled); + } +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/Rules/BaseLintRule.cs b/cmf-cli/Commands/build/business/BusinessLinter/Rules/BaseLintRule.cs new file mode 100644 index 000000000..024ab40ae --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/Rules/BaseLintRule.cs @@ -0,0 +1,20 @@ +using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cmf.CLI.Commands.build.business.BusinessLinter.Rules; + +internal abstract class BaseLintRule : ILintRule +{ + protected readonly ILintLogger _logger; + + protected BaseLintRule(ILintLogger logger) + { + _logger = logger; + } + + public abstract string RuleName { get; } + public abstract string RuleDescription { get; } + public bool IsEnabled { get; set; } = true; + + public abstract void Analyze(MethodDeclarationSyntax methodNode, string filePath, string className); +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/Rules/NoLoadInForeachRule.cs b/cmf-cli/Commands/build/business/BusinessLinter/Rules/NoLoadInForeachRule.cs new file mode 100644 index 000000000..0e4e6b29f --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/Rules/NoLoadInForeachRule.cs @@ -0,0 +1,67 @@ +using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Linq; + +namespace Cmf.CLI.Commands.build.business.BusinessLinter.Rules; + +internal class NoLoadInForeachRule : BaseLintRule +{ + public NoLoadInForeachRule(ILintLogger logger) : base(logger) + { + } + + public override string RuleName => "NoLoadInForeach"; + + public override string RuleDescription => "Load() method should not be called inside foreach loops"; + + public override void Analyze(MethodDeclarationSyntax methodNode, string filePath, string className) + { + if (methodNode.Body == null) + { + return; + } + + // Find all foreach statements in the method + var foreachStatements = methodNode.Body.DescendantNodes() + .OfType(); + + foreach (var foreachStatement in foreachStatements) + { + // Find all invocation expressions within the foreach + var invocations = foreachStatement.DescendantNodes() + .OfType(); + + foreach (var invocation in invocations) + { + // Check if this is a Load() method call + if (IsLoadMethodCall(invocation)) + { + var lineSpan = invocation.GetLocation().GetLineSpan(); + var line = lineSpan.StartLinePosition.Line + 1; // Line numbers are 0-based + + _logger.Warning($"[{RuleName}] {filePath}:{line} - {RuleDescription}. Found in class '{className}', method '{methodNode.Identifier.Text}'."); + } + } + } + } + + private bool IsLoadMethodCall(InvocationExpressionSyntax invocation) + { + // Check if the invocation is for a method named "Load" + // Handle both simple calls like "obj.Load()" and chained calls like "material.Facility.Load()" + + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.ValueText == "Load"; + } + + if (invocation.Expression is IdentifierNameSyntax identifier) + { + return identifier.Identifier.ValueText == "Load"; + } + + return false; + } +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/SolutionLinter.cs b/cmf-cli/Commands/build/business/BusinessLinter/SolutionLinter.cs new file mode 100644 index 000000000..e4a14a94d --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/SolutionLinter.cs @@ -0,0 +1,80 @@ +using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Cmf.CLI.Commands.build.business.BusinessLinter; + +internal class SolutionLinter +{ + private readonly IRuleFactory _ruleFactory; + private readonly string _solutionPath; + private readonly IList _files; + + public SolutionLinter(IRuleFactory ruleFactory, string solutionPath, IEnumerable files) + { + _ruleFactory = ruleFactory; + _solutionPath = solutionPath; + _files = files.ToList(); + } + + public async Task LintSolution() + { + var solutionLoader = new SolutionLoader(_solutionPath); + var workspace = solutionLoader.ReLoadSolution(); + var projects = workspace.CurrentSolution.Projects; + var rules = _ruleFactory.CreateEnabledRules().ToList(); + + if (!rules.Any()) + { + Console.WriteLine("No linting rules are enabled."); + return; + } + + Console.WriteLine($"Running {rules.Count} linting rule(s)..."); + + foreach (var project in projects) + { + foreach (var document in project.Documents) + { + // Only lint specified files, or all files if none specified + if (_files.Any() && !_files.Contains(document.Name)) + { + continue; + } + + var syntaxTree = await document.GetSyntaxTreeAsync(); + + if (syntaxTree is null) + { + continue; + } + + var root = syntaxTree.GetRoot(); + var filePath = document.FilePath ?? document.Name; + + // Find all class declarations + var classDeclarations = root.DescendantNodes().OfType(); + + foreach (var classDeclaration in classDeclarations) + { + var className = classDeclaration.Identifier.Text; + var methodNodes = classDeclaration.DescendantNodes().OfType(); + + foreach (var methodNode in methodNodes) + { + // Apply all enabled rules to each method + foreach (var rule in rules) + { + rule.Analyze(methodNode, filePath, className); + } + } + } + } + } + + Console.WriteLine("Linting complete."); + } +} diff --git a/cmf-cli/Commands/build/business/BusinessLinter/SolutionLoader.cs b/cmf-cli/Commands/build/business/BusinessLinter/SolutionLoader.cs new file mode 100644 index 000000000..ad007323d --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/SolutionLoader.cs @@ -0,0 +1,41 @@ +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; + +namespace Cmf.CLI.Commands.build.business.BusinessLinter; + +/// +/// Loads a visual studio solution and make it ready to use by Roslyn +/// +internal class SolutionLoader +{ + private readonly string _solutionPath; + + /// + /// Initializes a new instance of the class. + /// + /// The solution path. + public SolutionLoader(string solutionPath) + { + _solutionPath = solutionPath; + } + + /// + /// LoadSolution from path + /// + /// + public MSBuildWorkspace ReLoadSolution() + { + if (!MSBuildLocator.IsRegistered) + { + MSBuildLocator.RegisterDefaults(); + } + + var _ = typeof(Microsoft.CodeAnalysis.CSharp.Formatting.CSharpFormattingOptions); + + var workspace = MSBuildWorkspace.Create(); + + workspace.OpenSolutionAsync(_solutionPath).Wait(); + + return workspace; + } +} diff --git a/cmf-cli/Commands/build/business/LintCommand.cs b/cmf-cli/Commands/build/business/LintCommand.cs new file mode 100644 index 000000000..fa25b6bdb --- /dev/null +++ b/cmf-cli/Commands/build/business/LintCommand.cs @@ -0,0 +1,71 @@ +using Cmf.CLI.Commands.build.business.BusinessLinter; +using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; +using Cmf.CLI.Commands.build.business.BusinessLinter.Extensions; +using Cmf.CLI.Core.Attributes; +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; +using System.IO.Abstractions; +using System.Text; + +namespace Cmf.CLI.Commands.build.business; + +/// +/// This command lints Business package code files +/// +[CmfCommand(name: "lint", ParentId = "build_business", Id = "build_business_lint")] +public class LintCommand : BaseCommand +{ + public LintCommand() + { + } + + public LintCommand(IFileSystem fileSystem) : base(fileSystem) + { + } + + /// + /// configure the command + /// + /// + public override void Configure(Command cmd) + { + cmd.AddArgument(new Argument( + name: "solutionPath", + description: "The solution path" + )); + + var filesArgument = new Argument( + name: "files", + description: "The files to lint" + ) + { + Arity = ArgumentArity.ZeroOrMore + }; + + cmd.AddArgument(filesArgument); + + cmd.Handler = CommandHandler.Create(Execute); + } + + /// + /// Executes this instance. + /// + public void Execute(string solutionPath, string[] files) + { + if (string.IsNullOrEmpty(solutionPath)) + { + return; + } + + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + // TODO: Can be replaced when cli implements Dependency injection + var services = new ServiceCollection(); + services.AddLinterServices(); + var serviceProvider = services.BuildServiceProvider(); + + var solutionLinter = new SolutionLinter(serviceProvider.GetService(), solutionPath, files); + solutionLinter.LintSolution().Wait(); + } +} diff --git a/tests/Specs/BusinessLinter.cs b/tests/Specs/BusinessLinter.cs new file mode 100644 index 000000000..9400b2833 --- /dev/null +++ b/tests/Specs/BusinessLinter.cs @@ -0,0 +1,233 @@ +using Autofac.Extras.Moq; +using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; +using Cmf.CLI.Commands.build.business.BusinessLinter.Rules; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Moq; +using Xunit; + +namespace tests.Specs; + +public class BusinessLinter +{ + [Fact] + public void NoLoadInForeachRule_WhenLoadCalledInForeach_ShouldLogWarning() + { + // Arrange + using var mock = AutoMock.GetLoose(); + var logger = mock.Mock(); + + var code = @" +using System.Data; + +public class TestClass +{ + public void ProcessMaterials(DataRowCollection rows) + { + foreach (DataRow dataRow in rows) + { + var material = CreateMaterial(); + material.Name = (string)dataRow[""Name""]; + material.Load(); + } + } + + private dynamic CreateMaterial() => null; +}"; + + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var root = syntaxTree.GetRoot(); + var methodNode = root.DescendantNodes().OfType().First(); + + var rule = mock.Create(); + + // Act + rule.Analyze(methodNode, "TestFile.cs", "TestClass"); + + // Assert + logger.Verify(x => x.Warning(It.Is(s => s.Contains("NoLoadInForeach"))), Times.Once); + } + + [Fact] + public void NoLoadInForeachRule_WhenChainedLoadCalledInForeach_ShouldLogWarning() + { + // Arrange + using var mock = AutoMock.GetLoose(); + var logger = mock.Mock(); + + var code = @" +using System.Data; + +public class TestClass +{ + public void ProcessMaterials(DataRowCollection rows) + { + foreach (DataRow dataRow in rows) + { + var material = CreateMaterial(); + material.Facility.Load(); + } + } + + private dynamic CreateMaterial() => null; +}"; + + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var root = syntaxTree.GetRoot(); + var methodNode = root.DescendantNodes().OfType().First(); + + var rule = mock.Create(); + + // Act + rule.Analyze(methodNode, "TestFile.cs", "TestClass"); + + // Assert + logger.Verify(x => x.Warning(It.Is(s => s.Contains("NoLoadInForeach"))), Times.Once); + } + + [Fact] + public void NoLoadInForeachRule_WhenMultipleLoadCallsInForeach_ShouldLogMultipleWarnings() + { + // Arrange + using var mock = AutoMock.GetLoose(); + var logger = mock.Mock(); + + var code = @" +using System.Data; + +public class TestClass +{ + public void ProcessMaterials(DataRowCollection rows) + { + foreach (DataRow dataRow in rows) + { + var material = CreateMaterial(); + material.Load(); + material.Facility.Load(); + } + } + + private dynamic CreateMaterial() => null; +}"; + + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var root = syntaxTree.GetRoot(); + var methodNode = root.DescendantNodes().OfType().First(); + + var rule = mock.Create(); + + // Act + rule.Analyze(methodNode, "TestFile.cs", "TestClass"); + + // Assert + logger.Verify(x => x.Warning(It.Is(s => s.Contains("NoLoadInForeach"))), Times.Exactly(2)); + } + + [Fact] + public void NoLoadInForeachRule_WhenLoadCalledOutsideForeach_ShouldNotLogWarning() + { + // Arrange + using var mock = AutoMock.GetLoose(); + var logger = mock.Mock(); + + var code = @" +using System.Data; + +public class TestClass +{ + public void ProcessMaterial() + { + var material = CreateMaterial(); + material.Load(); + } + + private dynamic CreateMaterial() => null; +}"; + + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var root = syntaxTree.GetRoot(); + var methodNode = root.DescendantNodes().OfType().First(); + + var rule = mock.Create(); + + // Act + rule.Analyze(methodNode, "TestFile.cs", "TestClass"); + + // Assert + logger.Verify(x => x.Warning(It.IsAny()), Times.Never); + } + + [Fact] + public void NoLoadInForeachRule_WhenNoLoadCallsInForeach_ShouldNotLogWarning() + { + // Arrange + using var mock = AutoMock.GetLoose(); + var logger = mock.Mock(); + + var code = @" +using System.Data; + +public class TestClass +{ + public void ProcessMaterials(DataRowCollection rows) + { + foreach (DataRow dataRow in rows) + { + var material = CreateMaterial(); + material.Name = (string)dataRow[""Name""]; + } + } + + private dynamic CreateMaterial() => null; +}"; + + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var root = syntaxTree.GetRoot(); + var methodNode = root.DescendantNodes().OfType().First(); + + var rule = mock.Create(); + + // Act + rule.Analyze(methodNode, "TestFile.cs", "TestClass"); + + // Assert + logger.Verify(x => x.Warning(It.IsAny()), Times.Never); + } + + [Fact] + public void NoLoadInForeachRule_WhenNestedForeachWithLoad_ShouldLogWarning() + { + // Arrange + using var mock = AutoMock.GetLoose(); + var logger = mock.Mock(); + + var code = @" +using System.Collections.Generic; + +public class TestClass +{ + public void ProcessData(List> data) + { + foreach (var outer in data) + { + foreach (var item in outer) + { + item.Load(); + } + } + } +}"; + + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var root = syntaxTree.GetRoot(); + var methodNode = root.DescendantNodes().OfType().First(); + + var rule = mock.Create(); + + // Act + rule.Analyze(methodNode, "TestFile.cs", "TestClass"); + + // Assert + logger.Verify(x => x.Warning(It.Is(s => s.Contains("NoLoadInForeach"))), Times.Once); + } +} From 52f8df6e9731b5244cab9d09d102530926729ef1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:44:12 +0000 Subject: [PATCH 3/6] Add linter documentation and test fixture file Co-authored-by: joaoopereira <3718704+joaoopereira@users.noreply.github.com> --- .../build/business/BusinessLinter/README.md | 140 ++++++++++++++++++ .../MaterialOrchestration.cs | 69 +++++++++ 2 files changed, 209 insertions(+) create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/README.md create mode 100644 tests/Fixtures/new-packages/Cmf.Custom.Business/Cmf.Custom.Orchestration/MaterialOrchestration.cs diff --git a/cmf-cli/Commands/build/business/BusinessLinter/README.md b/cmf-cli/Commands/build/business/BusinessLinter/README.md new file mode 100644 index 000000000..5a2be490a --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/README.md @@ -0,0 +1,140 @@ +# Business Package Linter + +## Overview + +The Business Package Linter is a code analysis tool designed to detect problematic code patterns in Business packages. It uses Roslyn (Microsoft.CodeAnalysis) to parse and analyze C# code files. + +## Usage + +The linter can be invoked using the CLI: + +```bash +cmf build business lint [files...] +``` + +### Parameters + +- `solution-path` (required): Path to the solution file (.sln) +- `files` (optional): Specific files to lint. If not provided, all files in the solution will be linted. + +### Example + +```bash +# Lint all files in the solution +cmf build business lint ./MyProject/MyProject.sln + +# Lint specific files +cmf build business lint ./MyProject/MyProject.sln MyOrchestration.cs MyService.cs +``` + +## Rules + +The linter currently implements the following rules: + +### NoLoadInForeach + +**Description**: Detects calls to `Load()` methods inside `foreach` loops, which can lead to performance issues. + +**Rationale**: Calling `Load()` for each item in a loop can result in unnecessary resource usage and poor performance. Collection loads should be used instead. + +**Example of problematic code**: + +```csharp +foreach (DataRow dataRow in results.Tables[0].Rows) +{ + IMaterial material = _entityFactory.Create(); + material.Name = (string)dataRow["Name"]; + material.Load(); // Bad: Load() inside foreach + material.Facility.Load(); // Bad: Load() inside foreach + materials.Add(material); +} +``` + +**Recommended approach**: + +```csharp +// Collect all materials first +var materials = new List(); +foreach (DataRow dataRow in results.Tables[0].Rows) +{ + IMaterial material = _entityFactory.Create(); + material.Name = (string)dataRow["Name"]; + materials.Add(material); +} + +// Load all at once using collection load +materials.LoadCollection(); +``` + +## Architecture + +The linter follows a modular, extensible architecture: + +``` +BusinessLinter/ +├── Abstractions/ # Interfaces for core components +│ ├── ILintLogger.cs # Logging interface +│ ├── ILintRule.cs # Base interface for linting rules +│ └── IRuleFactory.cs # Factory for creating rule instances +├── Rules/ # Individual linting rules +│ ├── BaseLintRule.cs # Base class for rules +│ └── NoLoadInForeachRule.cs +├── Extensions/ # Extension methods +│ └── ServiceCollectionExtensions.cs +├── LintLogger.cs # Console logger implementation +├── RuleFactory.cs # Rule factory implementation +├── SolutionLinter.cs # Main orchestrator +└── SolutionLoader.cs # Loads solutions using Roslyn +``` + +## Adding New Rules + +To add a new linting rule: + +1. Create a new class in the `Rules/` folder that inherits from `BaseLintRule` +2. Implement the required properties and methods: + - `RuleName`: Unique identifier for the rule + - `RuleDescription`: Human-readable description + - `Analyze()`: The logic to detect the code pattern + +3. Register the rule in `Extensions/ServiceCollectionExtensions.cs`: + +```csharp +services.AddTransient(); +``` + +### Example Rule Implementation + +```csharp +internal class MyCustomRule : BaseLintRule +{ + public MyCustomRule(ILintLogger logger) : base(logger) { } + + public override string RuleName => "MyCustomRule"; + + public override string RuleDescription => "Description of what this rule checks"; + + public override void Analyze(MethodDeclarationSyntax methodNode, string filePath, string className) + { + // Your analysis logic here + // Use _logger.Warning() or _logger.Error() to report issues + } +} +``` + +## Configuration + +Rules can be enabled or disabled using the `IsEnabled` property. By default, all rules are enabled. Future enhancements may include configuration file support for more granular control. + +## Testing + +Unit tests for the linter are located in `tests/Specs/BusinessLinter.cs`. Tests use Moq and Autofac for dependency injection and mocking. + +## Future Enhancements + +- Configuration file support for enabling/disabling rules +- Rule severity levels (Warning, Error, Info) +- Custom rule parameters via configuration +- Integration with build pipelines +- HTML/JSON report generation +- Additional rules for common anti-patterns diff --git a/tests/Fixtures/new-packages/Cmf.Custom.Business/Cmf.Custom.Orchestration/MaterialOrchestration.cs b/tests/Fixtures/new-packages/Cmf.Custom.Business/Cmf.Custom.Orchestration/MaterialOrchestration.cs new file mode 100644 index 000000000..ef81455ac --- /dev/null +++ b/tests/Fixtures/new-packages/Cmf.Custom.Business/Cmf.Custom.Orchestration/MaterialOrchestration.cs @@ -0,0 +1,69 @@ +using System.Data; +using System.Collections.Generic; + +namespace Cmf.Custom.Orchestration +{ + /// + /// Example class demonstrating Load() calls in foreach loops (anti-pattern) + /// This file is used for testing the Business Package Linter + /// + public class MaterialOrchestration + { + private readonly dynamic _entityFactory; + + public MaterialOrchestration(dynamic entityFactory) + { + _entityFactory = entityFactory; + } + + /// + /// BAD PRACTICE: This method calls Load() inside a foreach loop + /// + public List GetMaterialsWithLoadInLoop(DataTable results) + { + var materials = new List(); + + foreach (DataRow dataRow in results.Rows) + { + dynamic material = _entityFactory.Create("Material"); + material.Name = (string)dataRow["Name"]; + material.Load(); // BAD: Load() inside foreach + material.Facility.Load(); // BAD: Load() inside foreach + materials.Add(material); + } + + return materials; + } + + /// + /// GOOD PRACTICE: This method collects objects first, then loads them + /// + public List GetMaterialsCorrectly(DataTable results) + { + var materials = new List(); + + foreach (DataRow dataRow in results.Rows) + { + dynamic material = _entityFactory.Create("Material"); + material.Name = (string)dataRow["Name"]; + materials.Add(material); + } + + // Load all materials at once (collection load) + // materials.LoadCollection(); + + return materials; + } + + /// + /// ACCEPTABLE: Load() is called outside the loop + /// + public dynamic GetSingleMaterial(string name) + { + dynamic material = _entityFactory.Create("Material"); + material.Name = name; + material.Load(); // OK: Not inside a loop + return material; + } + } +} From e4996a5cab5f656828204e9304a5ac6fd8e96677 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:46:14 +0000 Subject: [PATCH 4/6] Fix missing using statements in linter files Co-authored-by: joaoopereira <3718704+joaoopereira@users.noreply.github.com> --- .../build/business/BusinessLinter/Abstractions/IRuleFactory.cs | 2 ++ cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs | 1 + cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs | 3 +++ .../Commands/build/business/BusinessLinter/SolutionLinter.cs | 1 + 4 files changed, 7 insertions(+) diff --git a/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/IRuleFactory.cs b/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/IRuleFactory.cs index ce03b5d8e..722728475 100644 --- a/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/IRuleFactory.cs +++ b/cmf-cli/Commands/build/business/BusinessLinter/Abstractions/IRuleFactory.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; internal interface IRuleFactory diff --git a/cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs b/cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs index 556d727e3..be8e995ae 100644 --- a/cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs +++ b/cmf-cli/Commands/build/business/BusinessLinter/LintLogger.cs @@ -1,4 +1,5 @@ using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; +using System; namespace Cmf.CLI.Commands.build.business.BusinessLinter; diff --git a/cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs b/cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs index 2cb74a8a0..5917ecaf9 100644 --- a/cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs +++ b/cmf-cli/Commands/build/business/BusinessLinter/RuleFactory.cs @@ -1,5 +1,8 @@ using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; namespace Cmf.CLI.Commands.build.business.BusinessLinter; diff --git a/cmf-cli/Commands/build/business/BusinessLinter/SolutionLinter.cs b/cmf-cli/Commands/build/business/BusinessLinter/SolutionLinter.cs index e4a14a94d..df2d5ae27 100644 --- a/cmf-cli/Commands/build/business/BusinessLinter/SolutionLinter.cs +++ b/cmf-cli/Commands/build/business/BusinessLinter/SolutionLinter.cs @@ -1,6 +1,7 @@ using Cmf.CLI.Commands.build.business.BusinessLinter.Abstractions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; From 8080c1dc09c91d3ce9ef5d2aa769ef70a3141cfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:47:41 +0000 Subject: [PATCH 5/6] Add usage examples and future configuration schema Co-authored-by: joaoopereira <3718704+joaoopereira@users.noreply.github.com> --- .../business/BusinessLinter/USAGE_EXAMPLES.sh | 27 +++++++++ .../BusinessLinter/lint-config.schema.json | 55 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/USAGE_EXAMPLES.sh create mode 100644 cmf-cli/Commands/build/business/BusinessLinter/lint-config.schema.json diff --git a/cmf-cli/Commands/build/business/BusinessLinter/USAGE_EXAMPLES.sh b/cmf-cli/Commands/build/business/BusinessLinter/USAGE_EXAMPLES.sh new file mode 100644 index 000000000..227d7fe4c --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/USAGE_EXAMPLES.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Example script demonstrating how to use the Business Package Linter +# This script shows how to invoke the linter once the CLI is built + +# Example 1: Lint all files in a Business package solution +echo "Example 1: Lint all files in a Business package" +echo "cmf build business lint ./tests/Fixtures/new-packages/Cmf.Custom.Business/Business.sln" +echo "" + +# Example 2: Lint specific files +echo "Example 2: Lint specific files" +echo "cmf build business lint ./tests/Fixtures/new-packages/Cmf.Custom.Business/Business.sln MaterialOrchestration.cs" +echo "" + +# Example 3: Expected output when Load() is found in foreach +echo "Example 3: Expected output when problematic patterns are found" +echo "Running 1 linting rule(s)..." +echo "Warning: [NoLoadInForeach] /path/to/MaterialOrchestration.cs:23 - Load() method should not be called inside foreach loops. Found in class 'MaterialOrchestration', method 'GetMaterialsWithLoadInLoop'." +echo "Warning: [NoLoadInForeach] /path/to/MaterialOrchestration.cs:24 - Load() method should not be called inside foreach loops. Found in class 'MaterialOrchestration', method 'GetMaterialsWithLoadInLoop'." +echo "Linting complete." +echo "" + +# Example 4: Expected output when no issues are found +echo "Example 4: Expected output when no issues are found" +echo "Running 1 linting rule(s)..." +echo "Linting complete." diff --git a/cmf-cli/Commands/build/business/BusinessLinter/lint-config.schema.json b/cmf-cli/Commands/build/business/BusinessLinter/lint-config.schema.json new file mode 100644 index 000000000..1f8ae073d --- /dev/null +++ b/cmf-cli/Commands/build/business/BusinessLinter/lint-config.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Business Linter Configuration", + "description": "Configuration file for the Business Package Linter (future enhancement)", + "type": "object", + "properties": { + "rules": { + "type": "object", + "description": "Configuration for linting rules", + "properties": { + "NoLoadInForeach": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable this rule" + }, + "severity": { + "type": "string", + "enum": ["error", "warning", "info"], + "default": "warning", + "description": "Severity level for violations" + } + } + } + } + }, + "exclude": { + "type": "array", + "description": "File patterns to exclude from linting", + "items": { + "type": "string" + }, + "examples": [ + "**/Generated/**", + "**/*.Designer.cs" + ] + } + }, + "examples": [ + { + "rules": { + "NoLoadInForeach": { + "enabled": true, + "severity": "warning" + } + }, + "exclude": [ + "**/obj/**", + "**/bin/**" + ] + } + ] +} From 6b56df8226cc42fed4a2a0c8b94d5d4091de1f32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:52:30 +0000 Subject: [PATCH 6/6] Address code review feedback - add missing usings and improve error handling Co-authored-by: joaoopereira <3718704+joaoopereira@users.noreply.github.com> --- .../Commands/build/business/BusinessLinter/SolutionLoader.cs | 2 ++ cmf-cli/Commands/build/business/LintCommand.cs | 2 ++ tests/Specs/BusinessLinter.cs | 1 + 3 files changed, 5 insertions(+) diff --git a/cmf-cli/Commands/build/business/BusinessLinter/SolutionLoader.cs b/cmf-cli/Commands/build/business/BusinessLinter/SolutionLoader.cs index ad007323d..61a23a522 100644 --- a/cmf-cli/Commands/build/business/BusinessLinter/SolutionLoader.cs +++ b/cmf-cli/Commands/build/business/BusinessLinter/SolutionLoader.cs @@ -30,6 +30,8 @@ public MSBuildWorkspace ReLoadSolution() MSBuildLocator.RegisterDefaults(); } + // Force load of the CSharp formatting assembly to prevent runtime errors + // See: https://github.com/dotnet/roslyn/issues/48083 var _ = typeof(Microsoft.CodeAnalysis.CSharp.Formatting.CSharpFormattingOptions); var workspace = MSBuildWorkspace.Create(); diff --git a/cmf-cli/Commands/build/business/LintCommand.cs b/cmf-cli/Commands/build/business/LintCommand.cs index fa25b6bdb..5249ceb70 100644 --- a/cmf-cli/Commands/build/business/LintCommand.cs +++ b/cmf-cli/Commands/build/business/LintCommand.cs @@ -3,6 +3,7 @@ using Cmf.CLI.Commands.build.business.BusinessLinter.Extensions; using Cmf.CLI.Core.Attributes; using Microsoft.Extensions.DependencyInjection; +using System; using System.CommandLine; using System.CommandLine.NamingConventionBinder; using System.IO.Abstractions; @@ -55,6 +56,7 @@ public void Execute(string solutionPath, string[] files) { if (string.IsNullOrEmpty(solutionPath)) { + Console.Error.WriteLine("Error: Solution path is required."); return; } diff --git a/tests/Specs/BusinessLinter.cs b/tests/Specs/BusinessLinter.cs index 9400b2833..a441a9559 100644 --- a/tests/Specs/BusinessLinter.cs +++ b/tests/Specs/BusinessLinter.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Moq; +using System.Linq; using Xunit; namespace tests.Specs;