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
26 changes: 26 additions & 0 deletions src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,32 @@ public void Workspace_root_members_and_auxiliary_flags()
Assert.False(byPath["apps/web"].IsAuxiliary);
}

[Fact]
public void Follows_msbuild_import_for_shared_packagereferences()
{
using var repo = new TempRepo();
repo.Write("tests/Common.props", """
<Project>
<ItemGroup>
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
</ItemGroup>
</Project>
""")
.Write("tests/MyTests/MyTests.csproj", """
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\Common.props" />
<ItemGroup>
<PackageReference Include="NUnit" Version="3.14.0" />
</ItemGroup>
</Project>
""");

var comp = Detect(repo).Single(c => c.RelativePath == "tests/MyTests");
// The directly-declared dep and the one imported from ../Common.props both surface.
Assert.Contains(comp.Dependencies, d => d.Dependency.Name == "NUnit");
Assert.Contains(comp.Dependencies, d => d.Dependency.Name == "NUnit3TestAdapter");
}

[Fact]
public void Nearest_root_attribution_keeps_nested_files_with_child()
{
Expand Down
17 changes: 17 additions & 0 deletions src/Ivy.StackAnalyzer.Test/LanguageClassifierTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,29 @@ public class LanguageClassifierTests
[InlineData("Dockerfile", "Dockerfile")] // by filename
[InlineData("go.mod", "Go Module")] // by filename
[InlineData("styles/site.css", "CSS")]
[InlineData("shaders/water.fx", "HLSL")] // not InfluxData FLUX
[InlineData("kernels/conv.cl", "OpenCL")] // not Common Lisp / Cool
public void Resolves_expected_language(string path, string expected)
{
var c = Classifier.Classify(File(path));
Assert.Equal(expected, c.Language);
}

[Fact]
public void Binary_content_is_not_classified_as_source()
{
// A file whose extension maps to a language but whose bytes are binary
// (e.g. a Python pickle) must not be counted as code.
var tmp = Path.Combine(Path.GetTempPath(), "ivy-bin-" + Guid.NewGuid().ToString("N") + ".py");
System.IO.File.WriteAllBytes(tmp, [0x80, 0x03, 0x00, 0x01, 0x02]);
try
{
var c = Classifier.Classify(File("model.py", tmp));
Assert.Null(c.Language);
}
finally { System.IO.File.Delete(tmp); }
}

[Fact]
public void Unknown_extension_is_unclassified()
{
Expand Down
118 changes: 118 additions & 0 deletions src/Ivy.StackAnalyzer.Test/ManifestParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,124 @@ public void PyPi_requirements_txt()
Assert.DoesNotContain(m.Dependencies, d => d.Name.StartsWith('-'));
}

[Fact]
public void PyPi_pep735_dependency_groups()
{
const string toml = """
[project]
name = "svc"
dependencies = ["django>=5"]
[dependency-groups]
dev = ["pytest>=8", {include-group = "lint"}]
lint = ["ruff"]
""";
var m = new PyPiParser().Parse("pyproject.toml", toml);
Assert.Contains(m.Dependencies, d => d.Name == "pytest" && d.Scope == DependencyScope.Dev);
Assert.Contains(m.Dependencies, d => d.Name == "ruff");
Assert.DoesNotContain(m.Dependencies, d => d.Name == "include-group");
}

[Theory]
[InlineData("dev-requirements.txt")]
[InlineData("requirements-dev.txt")]
[InlineData("test-requirements.txt")]
public void PyPi_parses_alternate_requirements_names(string fileName)
{
var parser = new PyPiParser();
Assert.True(parser.CanParse(fileName));
var m = parser.Parse(fileName, "pytest==8.0\nblack\n");
Assert.Contains(m.Dependencies, d => d.Name == "pytest");
}

[Fact]
public void PyPi_setup_py_install_requires_and_extras()
{
const string setup = """
from setuptools import setup
setup(name="svc",
install_requires=["flask>=3", "requests"],
extras_require={"dev": ["pytest"]})
""";
var m = new PyPiParser().Parse("setup.py", setup);
Assert.Contains(m.Dependencies, d => d.Name == "flask");
Assert.Contains(m.Dependencies, d => d.Name == "requests");
Assert.Contains(m.Dependencies, d => d.Name == "pytest" && d.Scope == DependencyScope.Optional);
}

[Fact]
public void PyPi_conda_environment_yaml()
{
const string env = """
name: ml
channels: [conda-forge]
dependencies:
- python=3.10
- pytorch
- conda-forge::numpy
- pip:
- transformers==4.0
""";
var m = new PyPiParser().Parse("environment.yml", env);
Assert.Contains(m.Dependencies, d => d.Name == "pytorch");
Assert.Contains(m.Dependencies, d => d.Name == "numpy");
Assert.Contains(m.Dependencies, d => d.Name == "transformers");
Assert.DoesNotContain(m.Dependencies, d => d.Name == "python");
}

[Fact]
public void PyPi_pip_compile_marks_transitive_deps()
{
const string compiled = """
# This file is autogenerated by pip-compile
fastapi==0.110.0
# via -r requirements.in
starlette==0.36.0
# via fastapi
""";
var m = new PyPiParser().Parse("requirements.txt", compiled);
Assert.Contains(m.Dependencies, d => d.Name == "fastapi" && d.Scope == DependencyScope.Runtime);
Assert.Contains(m.Dependencies, d => d.Name == "starlette" && d.Scope == DependencyScope.Transitive);
}

[Fact]
public void Paket_dependencies_and_references()
{
const string deps = """
source https://api.nuget.org/v3/index.json
nuget FSharp.Core ~> 6.0
nuget Elmish

group Test
nuget NUnit3TestAdapter
""";
var m = new PaketParser().Parse("paket.dependencies", deps);
Assert.Equal("nuget", m.Ecosystem);
Assert.Contains(m.Dependencies, d => d.Name == "FSharp.Core");
Assert.Contains(m.Dependencies, d => d.Name == "Elmish" && d.Scope == DependencyScope.Runtime);
Assert.Contains(m.Dependencies, d => d.Name == "NUnit3TestAdapter" && d.Scope == DependencyScope.Dev);

const string refs = "FSharp.Core\nElmish\ngroup Test\n NUnit3TestAdapter\n";
var r = new PaketParser().Parse("Proj/paket.references", refs);
Assert.Contains(r.Dependencies, d => d.Name == "Elmish");
Assert.Contains(r.Dependencies, d => d.Name == "NUnit3TestAdapter" && d.Scope == DependencyScope.Dev);
}

[Fact]
public void NuGet_reads_msbuild_properties()
{
const string xml = """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
</Project>
""";
var m = new NuGetParser().Parse("App/App.csproj", xml);
Assert.Equal("true", m.Properties["UseWindowsForms"]);
Assert.Equal("WinExe", m.Properties["OutputType"]);
}

[Fact]
public void GoMod_require_block_and_indirect()
{
Expand Down
32 changes: 32 additions & 0 deletions src/Ivy.StackAnalyzer.Test/PrismaDetectorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Ivy.StackAnalyzer.Detection;
using Xunit;

namespace Ivy.StackAnalyzer.Test;

public class PrismaDetectorTests
{
[Theory]
[InlineData("postgresql", "PostgreSQL")]
[InlineData("mysql", "MySQL")]
[InlineData("sqlite", "SQLite")]
[InlineData("mongodb", "MongoDB")]
public void Maps_datasource_provider_to_database(string provider, string expected)
{
var schema = $$"""
datasource db {
provider = "{{provider}}"
url = env("DATABASE_URL")
}
""";
Assert.Equal(expected, PrismaDetector.DatabaseFor(schema));
}

[Fact]
public void Ignores_generator_provider_and_dynamic_provider()
{
// A generator block's provider ("prisma-client-js") is not a database.
Assert.Null(PrismaDetector.DatabaseFor("""generator client { provider = "prisma-client-js" }"""));
// A provider read from an env var is not a literal engine.
Assert.Null(PrismaDetector.DatabaseFor("""datasource db { provider = env("DB_PROVIDER") }"""));
}
}
35 changes: 34 additions & 1 deletion src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ private static ComponentContext Ctx(
IEnumerable<string>? sdks = null,
IEnumerable<string>? extensions = null,
IEnumerable<string>? envVars = null,
IEnumerable<string>? scripts = null)
IEnumerable<string>? scripts = null,
IEnumerable<KeyValuePair<string, string>>? properties = null)
{
var names = (fileNames ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
return new ComponentContext
Expand All @@ -25,6 +26,7 @@ private static ComponentContext Ctx(
Languages = [],
Dependencies = (deps ?? []).ToList(),
Sdks = (sdks ?? []).ToList(),
Properties = new Dictionary<string, string>(properties ?? [], StringComparer.OrdinalIgnoreCase),
IsWorkspaceRoot = false,
IsAuxiliary = false,
FileNames = names,
Expand All @@ -37,6 +39,8 @@ private static ComponentContext Ctx(

private static EcosystemDependency Npm(string name) => new("npm", new Dependency(name, null, DependencyScope.Runtime));
private static EcosystemDependency NuGet(string name) => new("nuget", new Dependency(name, null, DependencyScope.Runtime));
private static EcosystemDependency Pypi(string name, DependencyScope scope = DependencyScope.Runtime)
=> new("pypi", new Dependency(name, null, scope));

private static readonly DataStore Data = DataStore.Load();

Expand Down Expand Up @@ -126,6 +130,35 @@ public void Strong_match_keeps_declared_confidence()
Assert.Equal(Confidence.High, react.Confidence);
}

[Fact]
public void Transitive_only_slot_tech_is_dropped_to_low()
{
// A framework/db/orm whose only support is a transitive dep (e.g. Django
// pulled into a CLI's pip-compile lockfile) must not enter a hash slot.
var engine = new RuleEngine(Data);
var hit = engine.Detect(Ctx(deps: [Pypi("django", DependencyScope.Transitive)]))
.FirstOrDefault(t => t.Name == "Django");
Assert.NotNull(hit);
Assert.Equal(Confidence.Low, hit!.Confidence);
}

[Fact]
public void Direct_slot_tech_keeps_high_confidence()
{
var engine = new RuleEngine(Data);
var hit = engine.Detect(Ctx(deps: [Pypi("django", DependencyScope.Runtime)]))
.Single(t => t.Name == "Django");
Assert.Equal(Confidence.High, hit.Confidence);
}

[Fact]
public void Detects_windows_forms_by_msbuild_property()
{
var engine = new RuleEngine(Data);
var result = engine.Detect(Ctx(properties: [new("UseWindowsForms", "true")]));
Assert.Contains(result, t => t.Name == "Windows Forms" && t.Category == TechCategory.Framework);
}

[Theory]
[InlineData("SUPABASE_URL", "Supabase")]
[InlineData("SLACK_WEBHOOK_URL", "Slack")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,6 @@ readme:
in `frontend/`.
metadata:
analyzerVersion: 0.1.0
rulesLoaded: 778
rulesLoaded: 779
languageDefsLoaded: 815
ignoredDirectories: []
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,6 @@ infrastructure:
- Dockerfile
metadata:
analyzerVersion: 0.1.0
rulesLoaded: 778
rulesLoaded: 779
languageDefsLoaded: 815
ignoredDirectories: []
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,6 @@ readme:
backed by Postgres.
metadata:
analyzerVersion: 0.1.0
rulesLoaded: 778
rulesLoaded: 779
languageDefsLoaded: 815
ignoredDirectories: []
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,6 @@ technologies:
infrastructure: []
metadata:
analyzerVersion: 0.1.0
rulesLoaded: 778
rulesLoaded: 779
languageDefsLoaded: 815
ignoredDirectories: []
4 changes: 3 additions & 1 deletion src/Ivy.StackAnalyzer/AnalyzerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ public sealed record AnalyzerOptions
/// <summary>Maximum number of README lines captured in the excerpt.</summary>
public int MaxReadmeLines { get; init; } = 120;

/// <summary>Maximum number of dependencies kept per parsed manifest.</summary>
/// <summary>Maximum number of dependencies kept per parsed manifest in the
/// <em>reported</em> output. Detection always runs against the full set, so this
/// only bounds report size — it never hides a technology from the rule engine.</summary>
public int MaxDependenciesPerManifest { get; init; } = 100;

/// <summary>Extra directories scanned for user-supplied language / detector data files.</summary>
Expand Down
4 changes: 4 additions & 0 deletions src/Ivy.StackAnalyzer/Components/ComponentContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ public sealed class ComponentContext
/// <summary>MSBuild SDK attributes seen in this component's project files.</summary>
public IReadOnlyList<string> Sdks { get; init; } = [];

/// <summary>MSBuild build properties merged across this component's project files.</summary>
public IReadOnlyDictionary<string, string> Properties { get; init; }
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

/// <summary>Bare file names present anywhere in this component.</summary>
public required IReadOnlySet<string> FileNames { get; init; }

Expand Down
Loading
Loading