Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<Project></Project>\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()
{
Expand Down
21 changes: 21 additions & 0 deletions src/Ivy.StackAnalyzer.Test/FileSystemScannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
8 changes: 8 additions & 0 deletions src/Ivy.StackAnalyzer/Components/ComponentDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ public IReadOnlyList<ComponentContext> Detect(IReadOnlyList<ClassifiedFile> 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;
}

Expand Down
58 changes: 51 additions & 7 deletions src/Ivy.StackAnalyzer/Scanning/FileSystemScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ public ScanResult Scan(string repoRoot, CancellationToken ct = default)
var files = new List<ScannedFile>();
var ignoredDirs = new SortedSet<string>(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
Expand All @@ -39,25 +44,35 @@ public ScanResult Scan(string repoRoot, CancellationToken ct = default)

private void Walk(
string dir, string root, GitignoreMatcher gitignore,
GitignoreMatcher attrVendored, GitignoreMatcher attrDocumentation,
List<ScannedFile> files, SortedSet<string> 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)
{
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
{
Expand All @@ -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
Expand All @@ -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; }
Expand Down Expand Up @@ -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)
Expand Down
Loading