diff --git a/src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs b/src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs index b57789d..0c39e87 100644 --- a/src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs +++ b/src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs @@ -36,6 +36,22 @@ public void Workspace_root_members_and_auxiliary_flags() Assert.False(byPath["apps/web"].IsAuxiliary); } + [Fact] + public void Sln_only_directory_is_not_a_phantom_component() + { + using var repo = new TempRepo(); + repo.Write("Makefile", "all:\n\tcc -o app src/main.c\n") + .Write("src/main.c", "int main(void){return 0;}\n") + .Write("proj/vs2022/app.sln", "Microsoft Visual Studio Solution File\n") + .Write("proj/vs2022/app.vcxproj", "\n"); + + var comps = Detect(repo); + // proj/vs2022 holds only build files (no owned source / manifest) — it must + // not be emitted as a standalone empty workspace-root component. + Assert.DoesNotContain(comps, c => c.RelativePath == "proj/vs2022"); + Assert.Contains(comps, c => c.RelativePath == "."); + } + [Fact] public void Follows_msbuild_import_for_shared_packagereferences() { diff --git a/src/Ivy.StackAnalyzer.Test/FileSystemScannerTests.cs b/src/Ivy.StackAnalyzer.Test/FileSystemScannerTests.cs index 0b76aae..a32a464 100644 --- a/src/Ivy.StackAnalyzer.Test/FileSystemScannerTests.cs +++ b/src/Ivy.StackAnalyzer.Test/FileSystemScannerTests.cs @@ -17,6 +17,27 @@ private static TempRepo Sample() return repo; } + [Fact] + public void GitAttributes_linguist_overrides_flag_files() + { + using var repo = new TempRepo(); + repo.Write(".gitattributes", + "tests/data/** linguist-vendored\nschemas/*.gen.cs linguist-generated\nrefmanual/** linguist-documentation\n") + .Write("src/App.cs", "class A {}\n") + .Write("tests/data/fixture.tex", "\\documentclass{article}\n") + .Write("schemas/Model.gen.cs", "class M {}\n") + .Write("refmanual/guide.cs", "class D {}\n"); + + var scan = new FileSystemScanner(Harness.Data, new AnalyzerOptions()).Scan(repo.Root); + var byPath = scan.Files.ToDictionary(f => f.RelativePath); + + Assert.False(byPath["src/App.cs"].IsVendored); + Assert.False(byPath["src/App.cs"].IsDocumentation); + Assert.True(byPath["tests/data/fixture.tex"].IsVendored); // linguist-vendored + Assert.True(byPath["schemas/Model.gen.cs"].IsVendored); // linguist-generated + Assert.True(byPath["refmanual/guide.cs"].IsDocumentation); // linguist-documentation + } + [Fact] public void Prunes_vendored_dirs_and_gitignored_files_by_default() { diff --git a/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs b/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs index 83e871a..742b233 100644 --- a/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs +++ b/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs @@ -125,6 +125,14 @@ public IReadOnlyList Detect(IReadOnlyList file }); } + // Drop phantom workspace-root components: a directory promoted to a root + // only by a bare `.sln`/`.slnx` (or workspace declarator) that owns no + // source and no parsed manifest is a build artifact of a real root, not a + // component. The repo root (".") is always kept. + components.RemoveAll(c => + c.RelativePath != "." && c.IsWorkspaceRoot + && c.Languages.Count == 0 && c.Manifests.Count == 0); + return components; } diff --git a/src/Ivy.StackAnalyzer/Scanning/FileSystemScanner.cs b/src/Ivy.StackAnalyzer/Scanning/FileSystemScanner.cs index eff0e80..dd0372a 100644 --- a/src/Ivy.StackAnalyzer/Scanning/FileSystemScanner.cs +++ b/src/Ivy.StackAnalyzer/Scanning/FileSystemScanner.cs @@ -24,9 +24,14 @@ public ScanResult Scan(string repoRoot, CancellationToken ct = default) var files = new List(); var ignoredDirs = new SortedSet(StringComparer.Ordinal); var gitignore = new GitignoreMatcher(); + // .gitattributes linguist overrides (the repo's own language-stat hints): + // `linguist-vendored`/`-generated` exclude like vendored; `-documentation` + // flags as docs. Reuses the gitignore glob engine (same pattern syntax). + var attrVendored = new GitignoreMatcher(); + var attrDocumentation = new GitignoreMatcher(); int total = 0; - Walk(repoRoot, repoRoot, gitignore, files, ignoredDirs, ref total, ct); + Walk(repoRoot, repoRoot, gitignore, attrVendored, attrDocumentation, files, ignoredDirs, ref total, ct); files.Sort((a, b) => string.CompareOrdinal(a.RelativePath, b.RelativePath)); return new ScanResult @@ -39,11 +44,15 @@ public ScanResult Scan(string repoRoot, CancellationToken ct = default) private void Walk( string dir, string root, GitignoreMatcher gitignore, + GitignoreMatcher attrVendored, GitignoreMatcher attrDocumentation, List files, SortedSet ignoredDirs, ref int total, CancellationToken ct) { ct.ThrowIfCancellationRequested(); + var dirRel = ToRelative(root, dir); + if (dirRel == ".") dirRel = ""; + // Load this directory's .gitignore before descending. At the repo root // ToRelative yields "." — normalize to "" so patterns aren't prefixed "./". if (_options.RespectGitignore) @@ -51,13 +60,19 @@ private void Walk( var giPath = Path.Combine(dir, ".gitignore"); if (File.Exists(giPath)) { - var baseDir = ToRelative(root, dir); - if (baseDir == ".") baseDir = ""; - try { gitignore.AddFile(baseDir, File.ReadAllText(giPath)); } + try { gitignore.AddFile(dirRel, File.ReadAllText(giPath)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { /* skip unreadable .gitignore */ } } } + // .gitattributes is independent of .gitignore (read regardless). + var gaPath = Path.Combine(dir, ".gitattributes"); + if (File.Exists(gaPath)) + { + try { AddGitAttributes(dirRel, File.ReadAllText(gaPath), attrVendored, attrDocumentation); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { /* skip unreadable .gitattributes */ } + } + string[] subDirs, dirFiles; try { @@ -76,7 +91,8 @@ private void Walk( var rel = ToRelative(root, file); if (_options.RespectGitignore && gitignore.IsIgnored(rel, isDirectory: false)) continue; - bool vendored = IsVendored(rel); + // vendored: vendor.yml patterns OR a .gitattributes linguist-vendored/-generated override. + bool vendored = IsVendored(rel) || attrVendored.IsIgnored(rel, isDirectory: false); if (vendored && !_options.IncludeVendored) { // counted as total, kept (flagged) so downstream can exclude from stats @@ -85,7 +101,7 @@ private void Walk( // Documentation / example files are flagged but NEVER pruned from the // walk: example dirs must still surface as components. The flag only // excludes them from language statistics downstream. - bool documentation = IsDocumentation(rel); + bool documentation = IsDocumentation(rel) || attrDocumentation.IsIgnored(rel, isDirectory: false); long len; try { len = new FileInfo(file).Length; } catch { len = 0; } @@ -126,10 +142,38 @@ private void Walk( continue; } - Walk(sub, root, gitignore, files, ignoredDirs, ref total, ct); + Walk(sub, root, gitignore, attrVendored, attrDocumentation, files, ignoredDirs, ref total, ct); } } + // Parse a .gitattributes file: `pattern attr1 attr2 ...`. Positive linguist + // overrides feed the matchers; negated/`=false` forms are ignored (we never + // force-exclude on a negative). Pattern syntax matches .gitignore globs. + private static void AddGitAttributes( + string baseDir, string content, GitignoreMatcher vendored, GitignoreMatcher documentation) + { + foreach (var raw in content.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith('#')) continue; + var sp = line.IndexOfAny([' ', '\t']); + if (sp < 0) continue; + var pattern = line[..sp]; + var attrs = line[sp..]; + if (HasAttr(attrs, "linguist-vendored") || HasAttr(attrs, "linguist-generated")) + vendored.AddFile(baseDir, pattern); + if (HasAttr(attrs, "linguist-documentation")) + documentation.AddFile(baseDir, pattern); + } + } + + private static bool HasAttr(string attrs, string name) + { + foreach (var tok in attrs.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + if (tok == name || tok == name + "=true") return true; // positive only + return false; + } + private bool IsVendored(string relativePath) { foreach (var rx in _data.VendorPatterns)