Detect(ComponentContext ctx)
+ {
+ foreach (var f in ctx.Files)
+ {
+ if (!string.Equals(f.File.FileName, "schema.prisma", StringComparison.OrdinalIgnoreCase))
+ continue;
+ string content;
+ try { content = File.ReadAllText(f.File.FullPath); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { continue; }
+
+ var db = DatabaseFor(content);
+ if (db is not null)
+ yield return new DetectedTechnology(
+ db, TechCategory.Database,
+ $"prisma datasource provider in {f.File.RelativePath}",
+ Confidence.High, ctx.RelativePath);
+ }
+ }
+
+ /// Maps the provider of a Prisma datasource block to a
+ /// canonical database name, or null if absent / dynamic / unknown.
+ public static string? DatabaseFor(string schemaPrismaContent)
+ {
+ // Scope to the datasource block — `generator` blocks also have a `provider`
+ // (e.g. "prisma-client-js") that must not be mistaken for a database.
+ var ds = DatasourceBlockRegex().Match(schemaPrismaContent);
+ if (!ds.Success) return null;
+ var prov = ProviderRegex().Match(ds.Groups["body"].Value);
+ if (!prov.Success) return null;
+ return prov.Groups["p"].Value.ToLowerInvariant() switch
+ {
+ "postgresql" => "PostgreSQL",
+ "mysql" => "MySQL",
+ "sqlite" => "SQLite",
+ "sqlserver" => "Microsoft SQL Server",
+ "mongodb" => "MongoDB",
+ "cockroachdb" => "CockroachDB",
+ _ => null,
+ };
+ }
+
+ [GeneratedRegex(@"datasource\s+[A-Za-z0-9_]+\s*\{(?[^}]*)\}", RegexOptions.Singleline)]
+ private static partial Regex DatasourceBlockRegex();
+
+ [GeneratedRegex(@"provider\s*=\s*""(?[^""]+)""")]
+ private static partial Regex ProviderRegex();
+}
diff --git a/src/Ivy.StackAnalyzer/Detection/RuleEngine.cs b/src/Ivy.StackAnalyzer/Detection/RuleEngine.cs
index a76e369..3812aaa 100644
--- a/src/Ivy.StackAnalyzer/Detection/RuleEngine.cs
+++ b/src/Ivy.StackAnalyzer/Detection/RuleEngine.cs
@@ -19,32 +19,40 @@ public RuleEngine(DataStore data) : this(data.Rules) { }
public RuleEngine(IReadOnlyList rules) => _rules = rules;
+ private static bool IsHashSlot(TechCategory c)
+ => c is TechCategory.Framework or TechCategory.Database or TechCategory.Orm;
+
public IReadOnlyList Detect(ComponentContext ctx)
{
- var matched = new List<(RuleDef Rule, string Evidence, bool Strong)>();
+ var matched = new List<(RuleDef Rule, string Evidence, bool Strong, bool Direct)>();
foreach (var rule in _rules)
{
var m = Match(rule, ctx);
- if (m is not null) matched.Add((rule, m.Value.Evidence, m.Value.Strong));
+ if (m is not null) matched.Add((rule, m.Value.Evidence, m.Value.Strong, m.Value.Direct));
}
// Apply supersedes: drop any tech that a present tech supersedes.
var presentIds = matched.Select(m => m.Rule.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
var superseded = new HashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var (rule, _, _) in matched)
+ foreach (var (rule, _, _, _) in matched)
foreach (var s in rule.Supersedes)
if (presentIds.Contains(s)) superseded.Add(s);
var result = new List();
- foreach (var (rule, evidence, strong) in matched)
+ foreach (var (rule, evidence, strong, direct) in matched)
{
if (superseded.Contains(rule.Id)) continue;
+ var category = CategoryMap.Parse(rule.Category);
// A dotenv-only match (env-var names are scaffolding, not proof of use)
// is downgraded to Low so hash/digest consumers can drop the noise.
var confidence = strong ? CategoryMap.ParseConfidence(rule.Confidence) : Confidence.Low;
+ // A hash-slot tech (framework/db/orm) supported only by transitive deps
+ // (e.g. FastAPI pulled in by a CLI's pip-compile lockfile) is not a real
+ // stack choice — drop it to Low so it never enters a hash slot.
+ if (!direct && IsHashSlot(category)) confidence = Confidence.Low;
result.Add(new DetectedTechnology(
rule.Name,
- CategoryMap.Parse(rule.Category),
+ category,
evidence,
confidence,
ctx.RelativePath));
@@ -57,21 +65,27 @@ public IReadOnlyList Detect(ComponentContext ctx)
}
///
- /// Returns evidence text + whether a strong facet matched (anything other
- /// than dotenv), or null if the rule doesn't match.
+ /// Returns evidence text, whether a strong facet matched (anything other
+ /// than dotenv), and whether any strong facet was direct (a non-dep facet,
+ /// or a dep that is not transitive); or null if the rule doesn't match.
///
- private (string Evidence, bool Strong)? Match(RuleDef rule, ComponentContext ctx)
+ private (string Evidence, bool Strong, bool Direct)? Match(RuleDef rule, ComponentContext ctx)
{
var m = rule.Match;
var evidence = new List();
+ bool direct = false; // a non-transitive dep, or any non-dep facet, matched
// Dependencies (exact)
foreach (var d in m.Deps)
{
- if (ctx.Dependencies.Any(x =>
+ var hit = ctx.Dependencies.FirstOrDefault(x =>
Eco(x.Ecosystem, d.Ecosystem) &&
- string.Equals(x.Dependency.Name, d.Name, StringComparison.OrdinalIgnoreCase)))
+ string.Equals(x.Dependency.Name, d.Name, StringComparison.OrdinalIgnoreCase));
+ if (hit is not null)
+ {
evidence.Add($"{d.Ecosystem} dep '{d.Name}'");
+ if (hit.Dependency.Scope != DependencyScope.Transitive) direct = true;
+ }
}
// Dependency prefixes
@@ -80,7 +94,11 @@ public IReadOnlyList Detect(ComponentContext ctx)
var hit = ctx.Dependencies.FirstOrDefault(x =>
Eco(x.Ecosystem, p.Ecosystem) &&
x.Dependency.Name.StartsWith(p.Prefix, StringComparison.OrdinalIgnoreCase));
- if (hit is not null) evidence.Add($"{p.Ecosystem} dep '{hit.Dependency.Name}'");
+ if (hit is not null)
+ {
+ evidence.Add($"{p.Ecosystem} dep '{hit.Dependency.Name}'");
+ if (hit.Dependency.Scope != DependencyScope.Transitive) direct = true;
+ }
}
// Dependency regex
@@ -89,14 +107,29 @@ public IReadOnlyList Detect(ComponentContext ctx)
var rx = GetRegex(r.Pattern);
var hit = ctx.Dependencies.FirstOrDefault(x =>
Eco(x.Ecosystem, r.Ecosystem) && rx.IsMatch(x.Dependency.Name));
- if (hit is not null) evidence.Add($"{r.Ecosystem} dep '{hit.Dependency.Name}'");
+ if (hit is not null)
+ {
+ evidence.Add($"{r.Ecosystem} dep '{hit.Dependency.Name}'");
+ if (hit.Dependency.Scope != DependencyScope.Transitive) direct = true;
+ }
}
+ // Non-dependency facets below (sdk/files/paths/extensions/scripts) are all
+ // direct, first-party evidence — a present config file or SDK is never
+ // "transitive". If any matches, the rule is directly supported.
+ int beforeNonDep = evidence.Count;
+
// SDK attribute
foreach (var sdk in m.Sdk)
if (ctx.Sdks.Any(s => string.Equals(s, sdk, StringComparison.OrdinalIgnoreCase)))
evidence.Add($"Sdk={sdk}");
+ // Build properties (e.g. MSBuild UseWindowsForms=true)
+ foreach (var p in m.Properties)
+ if (ctx.Properties.TryGetValue(p.Name, out var v)
+ && string.Equals(v, p.Value, StringComparison.OrdinalIgnoreCase))
+ evidence.Add($"{p.Name}={v}");
+
// Files (exact name)
foreach (var f in m.Files)
if (ctx.FileNames.Contains(f)) evidence.Add(f);
@@ -131,6 +164,8 @@ public IReadOnlyList Detect(ComponentContext ctx)
if (hit is not null) evidence.Add($"script '{hit}'");
}
+ if (evidence.Count > beforeNonDep) direct = true;
+
// Every facet above is a strong signal; dotenv (below) is weak.
bool strong = evidence.Count > 0;
@@ -140,7 +175,7 @@ public IReadOnlyList Detect(ComponentContext ctx)
evidence.Add($"env {prefix}*");
if (evidence.Count == 0) return null;
- return (string.Join("; ", evidence.Distinct().Take(4)), strong);
+ return (string.Join("; ", evidence.Distinct().Take(4)), strong, direct);
}
private static bool Eco(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
diff --git a/src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs b/src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs
index 9c80dc3..f1e4a20 100644
--- a/src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs
+++ b/src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs
@@ -21,6 +21,12 @@ public sealed record ParsedManifest
/// MSBuild Sdk attribute for *.csproj / *.fsproj.
public string? Sdk { get; init; }
+ /// MSBuild PropertyGroup scalar properties (e.g.
+ /// UseWindowsForms=true). Some frameworks have no package marker and are
+ /// only knowable from a build property.
+ public IReadOnlyDictionary Properties { get; init; }
+ = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
public ManifestFile ToManifestFile() => new(Path, Ecosystem, Dependencies);
}
diff --git a/src/Ivy.StackAnalyzer/Manifests/ManifestParserRegistry.cs b/src/Ivy.StackAnalyzer/Manifests/ManifestParserRegistry.cs
index f79c344..9632b78 100644
--- a/src/Ivy.StackAnalyzer/Manifests/ManifestParserRegistry.cs
+++ b/src/Ivy.StackAnalyzer/Manifests/ManifestParserRegistry.cs
@@ -15,6 +15,7 @@ public static IReadOnlyList Default() =>
[
new NpmParser(),
new NuGetParser(),
+ new PaketParser(),
new PyPiParser(),
new GoModParser(),
new CargoParser(),
diff --git a/src/Ivy.StackAnalyzer/Manifests/NuGetParser.cs b/src/Ivy.StackAnalyzer/Manifests/NuGetParser.cs
index 0e3ca10..28f8b04 100644
--- a/src/Ivy.StackAnalyzer/Manifests/NuGetParser.cs
+++ b/src/Ivy.StackAnalyzer/Manifests/NuGetParser.cs
@@ -19,6 +19,7 @@ public ParsedManifest Parse(string relativePath, string content)
{
var deps = new List();
string? sdk = null;
+ var properties = new Dictionary(StringComparer.OrdinalIgnoreCase);
try
{
var doc = XDocument.Parse(content);
@@ -27,6 +28,12 @@ public ParsedManifest Parse(string relativePath, string content)
{
sdk = (string?)root.Attribute("Sdk");
+ // Scalar properties from every (last value wins).
+ foreach (var pg in root.Descendants().Where(e => e.Name.LocalName == "PropertyGroup"))
+ foreach (var prop in pg.Elements())
+ if (!prop.HasElements)
+ properties[prop.Name.LocalName] = prop.Value.Trim();
+
foreach (var pr in root.Descendants().Where(e =>
e.Name.LocalName is "PackageReference" or "PackageVersion" or "GlobalPackageReference"))
{
@@ -47,6 +54,7 @@ public ParsedManifest Parse(string relativePath, string content)
Ecosystem = "nuget",
Dependencies = deps,
Sdk = sdk,
+ Properties = properties,
};
}
}
diff --git a/src/Ivy.StackAnalyzer/Manifests/PaketParser.cs b/src/Ivy.StackAnalyzer/Manifests/PaketParser.cs
new file mode 100644
index 0000000..77b9e08
--- /dev/null
+++ b/src/Ivy.StackAnalyzer/Manifests/PaketParser.cs
@@ -0,0 +1,66 @@
+namespace Ivy.StackAnalyzer.Manifests;
+
+///
+/// Parses Paket manifests (paket.dependencies / paket.references),
+/// the dependency manager many F#/.NET repos use instead of in-project
+/// PackageReferences. Emits NuGet-ecosystem dependencies so the existing
+/// .NET detectors fire. Deps under a group whose name contains "test" or
+/// "build" are marked .
+///
+public sealed class PaketParser : IManifestParser
+{
+ public bool CanParse(string fileName)
+ => string.Equals(fileName, "paket.dependencies", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(fileName, "paket.references", StringComparison.OrdinalIgnoreCase);
+
+ public ParsedManifest Parse(string relativePath, string content)
+ {
+ var fileName = relativePath[(relativePath.LastIndexOf('/') + 1)..];
+ bool isDependencies = fileName.Equals("paket.dependencies", StringComparison.OrdinalIgnoreCase);
+ var deps = new List();
+ var scope = DependencyScope.Runtime;
+
+ foreach (var raw in content.Split('\n'))
+ {
+ var line = raw.Trim();
+ if (line.Length == 0 || line.StartsWith("//") || line.StartsWith('#')) continue;
+
+ // `group Test` switches the active scope for subsequent lines.
+ if (line.StartsWith("group ", StringComparison.OrdinalIgnoreCase))
+ {
+ var groupName = line[6..].Trim();
+ scope = IsDevGroup(groupName) ? DependencyScope.Dev : DependencyScope.Runtime;
+ continue;
+ }
+
+ string? name = isDependencies ? PackageFromDependencyLine(line) : PackageFromReferenceLine(line);
+ if (!string.IsNullOrEmpty(name)) deps.Add(new Dependency(name!, null, scope));
+ }
+
+ return new ParsedManifest { Path = relativePath, Ecosystem = "nuget", Dependencies = deps };
+ }
+
+ private static bool IsDevGroup(string name)
+ => name.Contains("test", StringComparison.OrdinalIgnoreCase)
+ || name.Contains("build", StringComparison.OrdinalIgnoreCase);
+
+ // paket.dependencies: `nuget FSharp.Core ~> 6.0`. Only nuget sources are NuGet deps.
+ private static string? PackageFromDependencyLine(string line)
+ {
+ if (!line.StartsWith("nuget ", StringComparison.OrdinalIgnoreCase)) return null;
+ var rest = line[6..].Trim();
+ var sp = rest.IndexOfAny([' ', '\t']);
+ return sp < 0 ? rest : rest[..sp];
+ }
+
+ // paket.references: a bare package name per line (settings may follow). Skip
+ // file references and framework directives.
+ private static string? PackageFromReferenceLine(string line)
+ {
+ if (line.StartsWith("File:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("framework:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("redirects:", StringComparison.OrdinalIgnoreCase)) return null;
+ var sp = line.IndexOfAny([' ', '\t']);
+ return sp < 0 ? line : line[..sp];
+ }
+}
diff --git a/src/Ivy.StackAnalyzer/Manifests/PyPiParser.cs b/src/Ivy.StackAnalyzer/Manifests/PyPiParser.cs
index 12de54e..5d21676 100644
--- a/src/Ivy.StackAnalyzer/Manifests/PyPiParser.cs
+++ b/src/Ivy.StackAnalyzer/Manifests/PyPiParser.cs
@@ -4,14 +4,30 @@
namespace Ivy.StackAnalyzer.Manifests;
-/// Parses Python manifests: pyproject.toml, requirements*.txt, Pipfile.
+///
+/// Parses Python manifests: pyproject.toml (PEP 621, Poetry, PEP 735
+/// dependency-groups), Pipfile, setup.py (install_requires),
+/// conda environment.yml, and any *requirements*.txt. pip-compile
+/// lockfiles are recognised and their transitive closure is marked
+/// so it cannot fabricate a hash slot.
+///
public sealed partial class PyPiParser : IManifestParser
{
public bool CanParse(string fileName)
=> string.Equals(fileName, "pyproject.toml", StringComparison.OrdinalIgnoreCase)
|| string.Equals(fileName, "Pipfile", StringComparison.OrdinalIgnoreCase)
- || (fileName.StartsWith("requirements", StringComparison.OrdinalIgnoreCase)
- && fileName.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
+ || string.Equals(fileName, "setup.py", StringComparison.OrdinalIgnoreCase)
+ || IsCondaEnv(fileName)
+ || IsRequirementsTxt(fileName);
+
+ // dev-requirements.txt, requirements-dev.txt, test-requirements.txt, requirements.txt, …
+ private static bool IsRequirementsTxt(string f)
+ => f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
+ && f.Contains("requirements", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsCondaEnv(string f)
+ => string.Equals(f, "environment.yml", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(f, "environment.yaml", StringComparison.OrdinalIgnoreCase);
public ParsedManifest Parse(string relativePath, string content)
{
@@ -20,6 +36,8 @@ public ParsedManifest Parse(string relativePath, string content)
{
"pyproject.toml" => ParsePyproject(content),
"pipfile" => ParsePipfile(content),
+ "setup.py" => ParseSetupPy(content),
+ "environment.yml" or "environment.yaml" => ParseCondaEnv(content),
_ => ParseRequirements(content),
};
return new ParsedManifest { Path = relativePath, Ecosystem = "pypi", Dependencies = deps };
@@ -54,6 +72,15 @@ private static List ParsePyproject(string content)
if (g is TomlTable gt && gt.TryGetValue("dependencies", out var gd) && gd is TomlTable gdt)
AddFromTable(gdt, DependencyScope.Dev, deps, skipPython: true);
}
+
+ // PEP 735: [dependency-groups] dev = ["pytest", {include-group = "test"}]
+ // (the modern uv / pip convention). String values are PEP 508 specs;
+ // {include-group = ...} tables are references to other groups -> skip.
+ if (model.TryGetValue("dependency-groups", out var dg) && dg is TomlTable dgt)
+ foreach (var group in dgt.Values)
+ if (group is TomlArray dga)
+ foreach (var item in dga)
+ if (item is string s) AddFromSpecifier(s, DependencyScope.Dev, deps);
return deps;
}
@@ -70,21 +97,128 @@ private static List ParsePipfile(string content)
return deps;
}
+ // setup.py: extract install_requires=[...] and extras_require={...: [...]}.
+ private static List ParseSetupPy(string content)
+ {
+ var deps = new List();
+ foreach (Match block in RequiresBlockRegex().Matches(content))
+ {
+ var scope = block.Groups["kind"].Value.StartsWith("install", StringComparison.OrdinalIgnoreCase)
+ ? DependencyScope.Runtime : DependencyScope.Optional;
+ foreach (Match lit in StringLiteralRegex().Matches(block.Groups["body"].Value))
+ AddFromSpecifier(lit.Groups["v"].Value, scope, deps);
+ }
+ return deps;
+ }
+
+ // conda environment.yml: top-level `dependencies:` list, plus a nested `- pip:` list.
+ private static List ParseCondaEnv(string content)
+ {
+ var deps = new List();
+ bool inDeps = false;
+ int depsIndent = -1;
+ foreach (var raw in content.Split('\n'))
+ {
+ var line = raw.Replace("\t", " ").TrimEnd();
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0 || trimmed.StartsWith('#')) continue;
+ int indent = line.Length - line.TrimStart(' ').Length;
+
+ if (!inDeps)
+ {
+ if (trimmed is "dependencies:" || trimmed.StartsWith("dependencies:", StringComparison.Ordinal))
+ { inDeps = true; depsIndent = indent; }
+ continue;
+ }
+
+ // A non-list key at or above the `dependencies:` indent ends the block.
+ if (indent <= depsIndent && !trimmed.StartsWith('-')) { inDeps = false; continue; }
+ if (!trimmed.StartsWith('-')) continue;
+
+ var entry = trimmed[1..].Trim();
+ if (entry.Length == 0 || entry.StartsWith("pip:", StringComparison.OrdinalIgnoreCase)) continue;
+
+ // Strip a conda channel prefix (`conda-forge::numpy`) and skip the interpreter pin.
+ var sep = entry.IndexOf("::", StringComparison.Ordinal);
+ if (sep >= 0) entry = entry[(sep + 2)..];
+ entry = entry.Trim().Trim('"', '\'');
+ if (entry.Length == 0) continue;
+ if (entry.Equals("python", StringComparison.OrdinalIgnoreCase)
+ || entry.StartsWith("python=", StringComparison.OrdinalIgnoreCase)
+ || entry.StartsWith("python ", StringComparison.OrdinalIgnoreCase)) continue;
+
+ AddFromSpecifier(entry, DependencyScope.Runtime, deps);
+ }
+ return deps;
+ }
+
private static List ParseRequirements(string content)
{
+ // pip-compile output lists the full transitive closure as if it were direct.
+ // When we recognise that format, only deps requested via `-r`/`-c`/`-e`/`.in`
+ // stay Runtime; everything else is marked Transitive so it cannot fabricate a
+ // framework/db/orm hash slot.
+ bool compiled = LooksCompiled(content);
var deps = new List();
+ Dependency? pending = null; // the dep whose `# via` block we're reading
+ bool pendingDirect = false;
+
+ void Flush()
+ {
+ if (pending is null) return;
+ var scope = !compiled || pendingDirect ? DependencyScope.Runtime : DependencyScope.Transitive;
+ deps.Add(pending with { Scope = scope });
+ pending = null;
+ }
+
foreach (var raw in content.Split('\n'))
{
- var line = raw.Trim();
- if (line.Length == 0 || line.StartsWith('#') || line.StartsWith('-')) continue;
+ var line = raw.TrimEnd();
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0) { continue; }
+
+ // A `# via` continuation line belongs to the previous dep.
+ if (trimmed.StartsWith('#'))
+ {
+ if (pending is not null && ViaIsDirect(trimmed)) pendingDirect = true;
+ continue;
+ }
+ if (trimmed.StartsWith('-')) continue; // options like -r, --hash on their own line
+
+ Flush();
+
// Strip an inline comment introduced by whitespace + '#'.
+ var inlineDirect = false;
var hash = CommentRegex().Match(line);
- if (hash.Success) line = line[..hash.Index].Trim();
- AddFromSpecifier(line, DependencyScope.Runtime, deps);
+ if (hash.Success)
+ {
+ var comment = line[hash.Index..];
+ inlineDirect = ViaIsDirect(comment);
+ line = line[..hash.Index].Trim();
+ }
+ var parsed = ParseSpecifier(line);
+ if (parsed is null) continue;
+ pending = parsed;
+ pendingDirect = inlineDirect;
}
+ Flush();
return deps;
}
+ // Heuristics for a pip-compile / pip-tools generated lockfile.
+ private static bool LooksCompiled(string content)
+ => content.Contains("# via", StringComparison.Ordinal)
+ || content.Contains("pip-compile", StringComparison.OrdinalIgnoreCase)
+ || content.Contains("uv pip compile", StringComparison.OrdinalIgnoreCase);
+
+ // A `# via` target that references a requirements input (`-r foo.in`, `-c …`,
+ // `-e .`, or a `*.in` file) means the package was directly requested.
+ private static bool ViaIsDirect(string comment)
+ => comment.Contains("-r ", StringComparison.Ordinal)
+ || comment.Contains("-c ", StringComparison.Ordinal)
+ || comment.Contains("-e ", StringComparison.Ordinal)
+ || comment.Contains(".in", StringComparison.OrdinalIgnoreCase);
+
private static void AddFromTable(TomlTable table, DependencyScope scope, List into, bool skipPython)
{
foreach (var (key, value) in table)
@@ -102,15 +236,21 @@ TomlTable t when t.TryGetValue("version", out var v) => v?.ToString(),
private static void AddFromSpecifier(string? spec, DependencyScope scope, List into)
{
- if (string.IsNullOrWhiteSpace(spec)) return;
+ var dep = ParseSpecifier(spec);
+ if (dep is not null) into.Add(dep with { Scope = scope });
+ }
+
+ private static Dependency? ParseSpecifier(string? spec)
+ {
+ if (string.IsNullOrWhiteSpace(spec)) return null;
// Drop an environment marker (PEP 508): "foo>=1; python_version<'3.9'" -> "foo>=1".
var semi = spec.IndexOf(';');
if (semi >= 0) spec = spec[..semi];
var m = NameRegex().Match(spec.Trim());
- if (!m.Success) return;
+ if (!m.Success) return null;
var name = m.Groups["name"].Value;
var version = m.Groups["ver"].Success ? m.Groups["ver"].Value.Trim() : null;
- into.Add(new Dependency(name, version, scope));
+ return new Dependency(name, version, DependencyScope.Runtime);
}
private static string? NormalizeVersion(string? v)
@@ -121,4 +261,10 @@ private static void AddFromSpecifier(string? spec, DependencyScope scope, Listinstall_requires|extras_require|tests_require)\s*=\s*[\[{](?[^\]}]*)[\]}]", RegexOptions.Singleline)]
+ private static partial Regex RequiresBlockRegex();
+
+ [GeneratedRegex(@"['""](?[^'""]+)['""]")]
+ private static partial Regex StringLiteralRegex();
}
diff --git a/src/Ivy.StackAnalyzer/Models/DataModels.cs b/src/Ivy.StackAnalyzer/Models/DataModels.cs
index 2938bf3..463a89a 100644
--- a/src/Ivy.StackAnalyzer/Models/DataModels.cs
+++ b/src/Ivy.StackAnalyzer/Models/DataModels.cs
@@ -28,6 +28,13 @@ public sealed class DepPrefixRef
public string Prefix { get; set; } = "";
}
+/// A build-property match (e.g. MSBuild UseWindowsForms=true).
+public sealed class PropertyRef
+{
+ public string Name { get; set; } = "";
+ public string Value { get; set; } = "";
+}
+
///
/// The superset matcher. Any populated facet contributes; a rule matches when
/// any of its facets matches the component (logical OR across facets).
@@ -44,6 +51,9 @@ public sealed class MatchSpec
public List Sdk { get; set; } = [];
public List PathGlobs { get; set; } = [];
+ /// MSBuild build-property matches (name + expected value).
+ public List Properties { get; set; } = [];
+
/// Regexes matched against run-script command strings (e.g. package.json
/// scripts values). Detects tools invoked only via a script, such as bun test.
public List ScriptsRegex { get; set; } = [];
diff --git a/src/Ivy.StackAnalyzer/Pipeline.cs b/src/Ivy.StackAnalyzer/Pipeline.cs
index ab97115..ebf3402 100644
--- a/src/Ivy.StackAnalyzer/Pipeline.cs
+++ b/src/Ivy.StackAnalyzer/Pipeline.cs
@@ -36,7 +36,10 @@ private static StackDetection Run(string repoPath, AnalyzerOptions options, Canc
var parsers = new ManifestParserRegistry();
var classifier = new LanguageClassifier(data);
var ruleEngine = new RuleEngine(data);
- var detectors = options.AdditionalDetectors; // code escape hatch (PLAN.md §7c)
+ // Built-in code detectors run alongside the data rules and any user-supplied
+ // ones (code escape hatch, PLAN.md §7c).
+ IReadOnlyList detectors =
+ [new PrismaDetector(), .. options.AdditionalDetectors];
// 1. Walk
var scan = new FileSystemScanner(data, options).Scan(fullPath, ct);
@@ -60,7 +63,7 @@ private static StackDetection Run(string repoPath, AnalyzerOptions options, Canc
{
RelativePath = ctx.RelativePath,
Languages = ctx.Languages,
- Manifests = ctx.Manifests.Select(m => m.ToManifestFile()).ToList(),
+ Manifests = ctx.Manifests.Select(m => CapForReport(m, options.MaxDependenciesPerManifest)).ToList(),
Technologies = techs,
FileCount = ctx.FileCount,
SizeBytes = ctx.SizeBytes,
@@ -109,6 +112,13 @@ private static StackDetection Run(string repoPath, AnalyzerOptions options, Canc
};
}
+ // The dependency cap bounds the size of the *reported* manifest only; detection
+ // already ran against the full set (see ComponentDetector).
+ private static ManifestFile CapForReport(ParsedManifest m, int cap)
+ => m.Dependencies.Count <= cap
+ ? m.ToManifestFile()
+ : new ManifestFile(m.Path, m.Ecosystem, m.Dependencies.Take(cap).ToList());
+
private static IEnumerable Dedupe(IEnumerable techs)
=> techs
.GroupBy(t => (t.Name, t.Category))
diff --git a/src/Ivy.StackAnalyzer/Scanning/LanguageClassifier.cs b/src/Ivy.StackAnalyzer/Scanning/LanguageClassifier.cs
index a836eaa..3ff1e63 100644
--- a/src/Ivy.StackAnalyzer/Scanning/LanguageClassifier.cs
+++ b/src/Ivy.StackAnalyzer/Scanning/LanguageClassifier.cs
@@ -28,6 +28,11 @@ public ClassifiedFile Classify(ScannedFile file)
if (name is null)
return new ClassifiedFile { File = file };
+ // A binary blob whose extension happens to map to a language (e.g. a Python
+ // pickle `.p` → Gnuplot) must not be counted as source code.
+ if (IsBinary(file.FullPath))
+ return new ClassifiedFile { File = file };
+
return new ClassifiedFile
{
File = file,
@@ -36,6 +41,22 @@ public ClassifiedFile Classify(ScannedFile file)
};
}
+ // Cheap binary sniff: a NUL byte in the first few KB is a strong binary signal
+ // (UTF-8/16 text without a BOM never contains a lone NUL in normal content).
+ private static bool IsBinary(string fullPath)
+ {
+ try
+ {
+ using var fs = File.OpenRead(fullPath);
+ Span buf = stackalloc byte[4096];
+ int n = fs.Read(buf);
+ for (int i = 0; i < n; i++)
+ if (buf[i] == 0) return true;
+ return false;
+ }
+ catch { return false; }
+ }
+
private string? ResolveLanguage(ScannedFile file)
{
// 1. Exact filename (Dockerfile, Makefile, go.mod, ...)
@@ -64,7 +85,7 @@ public ClassifiedFile Classify(ScannedFile file)
private static readonly string[] CommonPriority =
[
"Markdown", "JSON", "YAML", "TypeScript", "TSX", "JavaScript", "JSX",
- "Python", "C#", "F#", "Java", "Kotlin", "Go", "Rust", "C", "C++",
+ "Python", "C#", "F#", "Java", "Kotlin", "Go", "Rust", "C", "C++", "OpenCL",
"Ruby", "PHP", "Swift", "Scala", "HTML", "CSS", "SCSS", "Sass", "Less",
"Vue", "Svelte", "Dart", "Elixir", "Shell", "PowerShell", "SQL", "XML",
"Objective-C", "Dockerfile", "Text",
diff --git a/src/Ivy.StackAnalyzer/StackDetection.cs b/src/Ivy.StackAnalyzer/StackDetection.cs
index 2081882..947f388 100644
--- a/src/Ivy.StackAnalyzer/StackDetection.cs
+++ b/src/Ivy.StackAnalyzer/StackDetection.cs
@@ -118,6 +118,6 @@ public enum TechCategory
Documentation,
}
-public enum DependencyScope { Runtime, Dev, Peer, Optional }
+public enum DependencyScope { Runtime, Dev, Peer, Optional, Transitive }
public enum Confidence { Low, Medium, High }
diff --git a/src/Ivy.StackAnalyzer/data/detectors/dotnet.yml b/src/Ivy.StackAnalyzer/data/detectors/dotnet.yml
index 600d838..4377a62 100644
--- a/src/Ivy.StackAnalyzer/data/detectors/dotnet.yml
+++ b/src/Ivy.StackAnalyzer/data/detectors/dotnet.yml
@@ -61,6 +61,16 @@
sdk: ["Microsoft.NET.Sdk.Worker"]
confidence: high
+# WinForms has no NuGet package and no XAML; its only reliable marker is the
+# MSBuild property true.
+- id: windows-forms
+ name: Windows Forms
+ category: framework
+ match:
+ properties:
+ - { name: UseWindowsForms, value: "true" }
+ confidence: high
+
- id: avalonia
name: Avalonia
category: framework
diff --git a/src/Ivy.StackAnalyzer/data/detectors/language.yml b/src/Ivy.StackAnalyzer/data/detectors/language.yml
index 37a4275..f1b717e 100644
--- a/src/Ivy.StackAnalyzer/data/detectors/language.yml
+++ b/src/Ivy.StackAnalyzer/data/detectors/language.yml
@@ -119,17 +119,21 @@
match:
extensions: [.lua]
confidence: high
+# `.m` is shared by MATLAB and Objective-C; in code repos it is overwhelmingly
+# Objective-C, so MATLAB keeps only its unambiguous `.matlab`.
- id: matlab
name: MATLAB
category: language
match:
- extensions: [.matlab, .m]
+ extensions: [.matlab]
confidence: high
+# `.h` is a C/C++ header, not Objective-C — claiming it produced a false
+# high-confidence Objective-C on every C repo. Keep `.m`/`.mm` only.
- id: objectivec
name: Objective-C
category: language
match:
- extensions: [.m, .mm, .h]
+ extensions: [.m, .mm]
confidence: high
- id: perl
name: Perl
diff --git a/src/Ivy.StackAnalyzer/data/languages.yml b/src/Ivy.StackAnalyzer/data/languages.yml
index a8bc20e..ced4498 100644
--- a/src/Ivy.StackAnalyzer/data/languages.yml
+++ b/src/Ivy.StackAnalyzer/data/languages.yml
@@ -195,7 +195,7 @@ Euphoria: { type: programming, extensions: [.e, .ex], interpreters: [eui, euiw]
"F*": { type: programming, extensions: [.fst, .fsti] }
FIGlet Font: { type: data, extensions: [.flf] }
FIRRTL: { type: programming, extensions: [.fir] }
-FLUX: { type: programming, extensions: [.fx, .flux] }
+FLUX: { type: programming, extensions: [.flux] }
Factor: { type: programming, extensions: [.factor], filenames: [.factor-boot-rc, .factor-rc] }
Fancy: { type: programming, extensions: [.fy, .fancypack], filenames: [Fakefile] }
Fantom: { type: programming, extensions: [.fan] }
diff --git a/src/Ivy.StackAnalyzer/data/vendor.yml b/src/Ivy.StackAnalyzer/data/vendor.yml
index 6a2088e..b0d3ca8 100644
--- a/src/Ivy.StackAnalyzer/data/vendor.yml
+++ b/src/Ivy.StackAnalyzer/data/vendor.yml
@@ -1,6 +1,8 @@
# Vendored / generated path patterns seeded from github-linguist (MIT), plus common build dirs.
# Each entry is a .NET regex tested against forward-slash relative paths.
patterns:
+ # Minified / bundled assets — generated, not authored; would inflate JS/CSS shares.
+ - "(^|/)[^/]+\\.min\\.(js|css)$"
# Generated lockfiles — excluded so they do not skew language statistics.
- "(^|/)package-lock\\.json$"
- "(^|/)npm-shrinkwrap\\.json$"