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)