diff --git a/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj b/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj index b60d4a9c..06fc7d54 100644 --- a/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj +++ b/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/src/TALXIS.CLI.Features.Workspace/WorkspaceFileFilter.cs b/src/TALXIS.CLI.Features.Workspace/WorkspaceFileFilter.cs new file mode 100644 index 00000000..e22b78e6 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/WorkspaceFileFilter.cs @@ -0,0 +1,301 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace TALXIS.CLI.Features.Workspace; + +/// +/// Decides whether a file inside the workspace should be excluded from +/// validation. Wraps two layers of rules: +/// +/// A built-in list of well-known throwaway directories +/// (node_modules, bin, obj, ...). Always +/// active unless the caller opts out. +/// Patterns parsed from a root-level .gitignore, when one +/// exists. Subset of the gitignore spec — enough to cover the +/// common "skip everything under foo/" pattern, deliberately +/// skips negations (!pattern). +/// +/// +internal sealed class WorkspaceFileFilter +{ + /// + /// Throwaway directory names that should be skipped by default. Common + /// across .NET, Node.js, IDE, and build-output directories — none of + /// these ever contain hand-authored Power Platform metadata. + /// + public static readonly IReadOnlyList DefaultIgnoredDirectories = new[] + { + "node_modules", + "bin", + "obj", + "out", + "dist", + "coverage", + ".git", + ".vs", + ".idea", + ".vscode", + ".cache", + ".nuget", + }; + + private readonly string _workspaceRoot; + private readonly HashSet _ignoredDirNames; + private readonly List _gitignoreRules; + private readonly bool _skipNodeProjects; + private readonly Dictionary _nodeProjectCache; + + public WorkspaceFileFilter(string workspaceRoot, bool applyDefaults, bool readGitignore, bool skipNodeProjects = true) + { + _workspaceRoot = NormalizeDirectory(workspaceRoot); + _ignoredDirNames = applyDefaults + ? new HashSet(DefaultIgnoredDirectories, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + _gitignoreRules = new List(); + _skipNodeProjects = skipNodeProjects; + _nodeProjectCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (readGitignore) + { + var gitignorePath = Path.Combine(_workspaceRoot, ".gitignore"); + if (File.Exists(gitignorePath)) + LoadGitignore(gitignorePath); + } + } + + /// + /// Number of patterns parsed from .gitignore. Useful for verbose + /// logging. + /// + public int GitignorePatternCount => _gitignoreRules.Count; + + /// + /// true when falls under any of + /// the configured ignore rules. + /// + public bool IsIgnored(string absolutePath) + { + if (string.IsNullOrEmpty(absolutePath)) + return false; + + // Quick check: any path component is a default-ignored directory. + if (_ignoredDirNames.Count > 0) + { + var relative = GetRelativePath(absolutePath); + foreach (var segment in SplitSegments(relative)) + { + if (_ignoredDirNames.Contains(segment)) + return true; + } + } + + if (_gitignoreRules.Count > 0) + { + var relative = GetRelativePath(absolutePath).Replace('\\', '/'); + foreach (var rule in _gitignoreRules) + { + if (rule.Matches(relative)) + return true; + } + } + + // Node/TypeScript project trees: if any ancestor of the file + // contains a package.json, treat the whole subtree as not-our-stuff. + // Covers tsconfig.json, package-lock.json, .eslintrc.json, etc. + // without having to maintain a basename allowlist. + if (_skipNodeProjects && IsInsideNodeProject(absolutePath)) + return true; + + return false; + } + + private bool IsInsideNodeProject(string absolutePath) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(absolutePath)); + var root = _workspaceRoot; + + while (!string.IsNullOrEmpty(dir)) + { + if (_nodeProjectCache.TryGetValue(dir, out var cached)) + return cached; + + bool isNodeProject = File.Exists(Path.Combine(dir, "package.json")); + _nodeProjectCache[dir] = isNodeProject; + if (isNodeProject) + return true; + + // Stop at the workspace root — we don't want to walk above it. + if (string.Equals(dir, root, StringComparison.OrdinalIgnoreCase)) + return false; + + var parent = Path.GetDirectoryName(dir); + if (string.IsNullOrEmpty(parent) || string.Equals(parent, dir, StringComparison.OrdinalIgnoreCase)) + return false; + dir = parent; + } + return false; + } + + private string GetRelativePath(string absolutePath) + { + if (Path.IsPathRooted(absolutePath)) + { + try + { + return Path.GetRelativePath(_workspaceRoot, absolutePath); + } + catch (ArgumentException) + { + return absolutePath; + } + } + return absolutePath; + } + + private static IEnumerable SplitSegments(string path) + { + foreach (var segment in path.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries)) + yield return segment; + } + + private static string NormalizeDirectory(string dir) + => Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + private void LoadGitignore(string path) + { + foreach (var raw in File.ReadAllLines(path)) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + continue; + // Negations are intentionally unsupported. Treat them as no-ops + // rather than try to invert prior matches. + if (line.StartsWith('!')) + continue; + + var rule = GitignoreRule.TryParse(line); + if (rule is not null) + _gitignoreRules.Add(rule); + } + } + + /// + /// Single parsed line from a .gitignore. Implements a subset of + /// the gitignore spec that covers the most common patterns: + /// directory-only suffix, leading-slash anchoring, ** wildcards. + /// + private sealed class GitignoreRule + { + private readonly Regex _regex; + private readonly bool _directoryOnly; + + private GitignoreRule(Regex regex, bool directoryOnly) + { + _regex = regex; + _directoryOnly = directoryOnly; + } + + public bool Matches(string relativePath) + { + if (_directoryOnly) + { + // For "pattern/" patterns, match if the rule hits any + // directory portion of the path. We don't know from the + // path alone whether a leaf is a file or a directory, so + // require the match to consume a path segment followed by + // either end-of-string or another slash. + var match = _regex.Match(relativePath); + while (match.Success) + { + var end = match.Index + match.Length; + if (end == relativePath.Length || relativePath[end] == '/') + return true; + match = match.NextMatch(); + } + return false; + } + return _regex.IsMatch(relativePath); + } + + public static GitignoreRule? TryParse(string line) + { + var directoryOnly = false; + if (line.EndsWith('/')) + { + directoryOnly = true; + line = line.TrimEnd('/'); + } + + if (line.Length == 0) + return null; + + var anchored = line.StartsWith('/'); + if (anchored) + line = line.TrimStart('/'); + + var pattern = BuildRegex(line, anchored); + try + { + var regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant); + return new GitignoreRule(regex, directoryOnly); + } + catch (ArgumentException) + { + return null; + } + } + + private static string BuildRegex(string glob, bool anchored) + { + var sb = new StringBuilder(); + sb.Append('^'); + if (!anchored && !glob.Contains('/')) + { + // Floating name pattern: matches anywhere in the path. + sb.Append("(.*/)?"); + } + else if (!anchored) + { + // Floating path pattern (e.g. "src/foo"): allow any prefix. + sb.Append("(.*/)?"); + } + + for (int i = 0; i < glob.Length; i++) + { + var c = glob[i]; + if (c == '*') + { + if (i + 1 < glob.Length && glob[i + 1] == '*') + { + // ** — any number of path segments. + sb.Append(".*"); + i++; + // Consume a following slash if present, so "**/foo" matches "foo" too. + if (i + 1 < glob.Length && glob[i + 1] == '/') + i++; + } + else + { + // * — any chars except '/'. + sb.Append("[^/]*"); + } + } + else if (c == '?') + { + sb.Append("[^/]"); + } + else if (c == '.' || c == '+' || c == '(' || c == ')' || c == '|' || c == '^' || c == '$' + || c == '{' || c == '}' || c == '[' || c == ']' || c == '\\') + { + sb.Append('\\').Append(c); + } + else + { + sb.Append(c); + } + } + sb.Append("(/.*)?$"); + return sb.ToString(); + } + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/WorkspaceValidateCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/WorkspaceValidateCliCommand.cs index d703aa64..ffc6443a 100644 --- a/src/TALXIS.CLI.Features.Workspace/WorkspaceValidateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/WorkspaceValidateCliCommand.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Core; @@ -9,7 +10,7 @@ namespace TALXIS.CLI.Features.Workspace; [CliReadOnly] [CliCommand( Name = "validate", - Description = "Validates solution workspace files against XSD schemas, checks for structural issues, and loads the metadata model.")] + Description = "Validates solution workspace files against XSD schemas, checks for structural issues, and loads the metadata model. Skips well-known throwaway directories (node_modules, bin, obj, ...), files under any Node/TS project (anything next to a package.json), and honors the workspace .gitignore by default.")] public sealed class WorkspaceValidateCliCommand : TxcLeafCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(WorkspaceValidateCliCommand)); @@ -20,6 +21,14 @@ public sealed class WorkspaceValidateCliCommand : TxcLeafCommand [CliOption(Name = "--file", Required = false, Description = "Validate a single file (relative path within the workspace).")] public string? File { get; set; } + [CliOption(Name = "--no-ignore", Required = false, Description = "Disable default ignore rules and skip reading .gitignore. Every file under the workspace will be validated, including node_modules and build output.")] + [DefaultValue(false)] + public bool NoIgnore { get; set; } + + [CliOption(Name = "--no-gitignore", Required = false, Description = "Apply built-in defaults (node_modules, bin, obj, ...) but ignore the workspace .gitignore.")] + [DefaultValue(false)] + public bool NoGitignore { get; set; } + protected override async Task ExecuteAsync() { var fullPath = System.IO.Path.GetFullPath(Path); @@ -48,7 +57,13 @@ protected override async Task ExecuteAsync() // Full workspace validation var validator = new WorkspaceValidator(); var report = validator.ValidateDirectory(fullPath); - results = report.Results; + + var allResults = report.Results; + results = ApplyIgnoreFilter(fullPath, allResults); + + int skipped = allResults.Count - results.Count; + if (skipped > 0) + Logger.LogInformation("Skipped {Skipped} result(s) from ignored paths.", skipped); // Show component summary if model loaded if (report.LoadedComponents != null) @@ -88,4 +103,21 @@ protected override async Task ExecuteAsync() $"Validation complete: {errors} error(s), {warnings} warning(s)"); return errors > 0 ? ExitError : ExitSuccess; } + + private IReadOnlyList ApplyIgnoreFilter( + string workspaceRoot, IReadOnlyList all) + { + if (NoIgnore) + return all; + + var filter = new WorkspaceFileFilter(workspaceRoot, applyDefaults: true, readGitignore: !NoGitignore); + var filtered = new List(all.Count); + foreach (var result in all) + { + if (result.FilePath is not null && filter.IsIgnored(result.FilePath)) + continue; + filtered.Add(result); + } + return filtered; + } } diff --git a/tests/TALXIS.CLI.Tests/Workspace/WorkspaceFileFilterTests.cs b/tests/TALXIS.CLI.Tests/Workspace/WorkspaceFileFilterTests.cs new file mode 100644 index 00000000..38afc59b --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Workspace/WorkspaceFileFilterTests.cs @@ -0,0 +1,170 @@ +using System.IO; +using TALXIS.CLI.Features.Workspace; +using Xunit; + +namespace TALXIS.CLI.Tests.Workspace; + +/// +/// Unit tests for . Cover the two rule +/// sources independently (defaults vs. .gitignore) plus the opt-out paths. +/// +public class WorkspaceFileFilterTests : IDisposable +{ + private readonly string _root; + + public WorkspaceFileFilterTests() + { + _root = Path.Combine(Path.GetTempPath(), "txc-tests", "filter-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + [Theory] + [InlineData("src/Modules/Core/Apps/Home.Presentation/TS/node_modules/excellib/lib/xlsx/workbook.xml", true)] + [InlineData("node_modules/foo.xml", true)] + [InlineData("src/bin/Debug/net10.0/foo.dll", true)] + [InlineData("src/obj/project.assets.json", true)] + [InlineData("src/Solutions/MySolution/Entities/account.xml", false)] + [InlineData("src/MyComponent/code.ts", false)] + public void DefaultIgnoredDirectories_AreFiltered(string relative, bool ignored) + { + var filter = new WorkspaceFileFilter(_root, applyDefaults: true, readGitignore: false); + + var absolute = Path.Combine(_root, relative.Replace('/', Path.DirectorySeparatorChar)); + Assert.Equal(ignored, filter.IsIgnored(absolute)); + } + + [Fact] + public void NoDefaults_NodeModulesPasses() + { + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: false); + + var absolute = Path.Combine(_root, "src", "node_modules", "foo.xml"); + Assert.False(filter.IsIgnored(absolute)); + } + + [Fact] + public void Gitignore_DirectoryPattern_IsHonored() + { + File.WriteAllText(Path.Combine(_root, ".gitignore"), "exports/\n"); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: true); + Assert.Equal(1, filter.GitignorePatternCount); + + Assert.True(filter.IsIgnored(Path.Combine(_root, "exports", "out.json"))); + Assert.True(filter.IsIgnored(Path.Combine(_root, "src", "exports", "deep.xml"))); + Assert.False(filter.IsIgnored(Path.Combine(_root, "src", "Solutions", "x.xml"))); + } + + [Fact] + public void Gitignore_AnchoredPattern_OnlyAtRoot() + { + File.WriteAllText(Path.Combine(_root, ".gitignore"), "/exports\n"); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: true); + + Assert.True(filter.IsIgnored(Path.Combine(_root, "exports", "x.xml"))); + Assert.False(filter.IsIgnored(Path.Combine(_root, "src", "exports", "x.xml"))); + } + + [Fact] + public void Gitignore_GlobExtensions_AreMatched() + { + File.WriteAllText(Path.Combine(_root, ".gitignore"), "*.log\n"); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: true); + + Assert.True(filter.IsIgnored(Path.Combine(_root, "build.log"))); + Assert.True(filter.IsIgnored(Path.Combine(_root, "src", "trace.log"))); + Assert.False(filter.IsIgnored(Path.Combine(_root, "src", "trace.xml"))); + } + + [Fact] + public void Gitignore_CommentsAndBlanksAreSkipped() + { + File.WriteAllText(Path.Combine(_root, ".gitignore"), + "# this is a comment\n\nbin/\n # padded comment too\n"); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: true); + Assert.Equal(1, filter.GitignorePatternCount); + } + + [Fact] + public void Gitignore_NegationsAreIgnored() + { + // We deliberately do not implement `!pattern` semantics. Negations + // should be parsed away without affecting other rules. + File.WriteAllText(Path.Combine(_root, ".gitignore"), + "node_modules/\n!node_modules/keep.xml\n"); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: true); + + Assert.True(filter.IsIgnored(Path.Combine(_root, "node_modules", "keep.xml"))); + Assert.Equal(1, filter.GitignorePatternCount); + } + + [Fact] + public void NoGitignoreFile_NoRulesLoaded() + { + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: true); + Assert.Equal(0, filter.GitignorePatternCount); + } + + [Fact] + public void Defaults_AndGitignore_Compose() + { + File.WriteAllText(Path.Combine(_root, ".gitignore"), "custom-junk/\n"); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: true, readGitignore: true); + + Assert.True(filter.IsIgnored(Path.Combine(_root, "node_modules", "x.xml"))); // default + Assert.True(filter.IsIgnored(Path.Combine(_root, "custom-junk", "x.xml"))); // gitignore + Assert.False(filter.IsIgnored(Path.Combine(_root, "src", "x.xml"))); + } + + [Fact] + public void NodeProject_TsConfigsAreSkipped() + { + var tsDir = Path.Combine(_root, "TS"); + Directory.CreateDirectory(tsDir); + File.WriteAllText(Path.Combine(tsDir, "package.json"), "{}"); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: false); + + Assert.True(filter.IsIgnored(Path.Combine(tsDir, "package.json"))); + Assert.True(filter.IsIgnored(Path.Combine(tsDir, "package-lock.json"))); + Assert.True(filter.IsIgnored(Path.Combine(tsDir, "tsconfig.json"))); + Assert.True(filter.IsIgnored(Path.Combine(tsDir, "src", "deep", "config.json"))); + } + + [Fact] + public void NodeProject_SiblingDirectoriesArentSkipped() + { + var tsDir = Path.Combine(_root, "TS"); + Directory.CreateDirectory(tsDir); + File.WriteAllText(Path.Combine(tsDir, "package.json"), "{}"); + + var solutionsDir = Path.Combine(_root, "Solutions"); + Directory.CreateDirectory(solutionsDir); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: false); + + Assert.False(filter.IsIgnored(Path.Combine(solutionsDir, "MySolution", "Entities", "account.xml"))); + } + + [Fact] + public void NodeProject_SkipDisabled_TsConfigsValidated() + { + var tsDir = Path.Combine(_root, "TS"); + Directory.CreateDirectory(tsDir); + File.WriteAllText(Path.Combine(tsDir, "package.json"), "{}"); + + var filter = new WorkspaceFileFilter(_root, applyDefaults: false, readGitignore: false, skipNodeProjects: false); + + Assert.False(filter.IsIgnored(Path.Combine(tsDir, "tsconfig.json"))); + } +}