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")));
+ }
+}