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
22 changes: 21 additions & 1 deletion src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ private static ComponentContext Ctx(
IEnumerable<string>? fileNames = null,
IEnumerable<string>? sdks = null,
IEnumerable<string>? extensions = null,
IEnumerable<string>? envVars = null)
IEnumerable<string>? envVars = null,
IEnumerable<string>? scripts = null)
{
var names = (fileNames ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
return new ComponentContext
Expand All @@ -30,6 +31,7 @@ private static ComponentContext Ctx(
FilePaths = names.Select(n => "apps/web/" + n).ToHashSet(StringComparer.Ordinal),
Extensions = (extensions ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase),
EnvVarNames = (envVars ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase),
Scripts = (scripts ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase),
};
}

Expand All @@ -46,6 +48,24 @@ public void Detects_framework_by_dependency()
Assert.Contains(result, t => t.Name == "React" && t.Category == TechCategory.Framework);
}

[Theory]
[InlineData("bun test")]
[InlineData("bun test --coverage")]
public void Detects_bun_test_from_package_json_script(string command)
{
var engine = new RuleEngine(Data);
var result = engine.Detect(Ctx(scripts: [command]));
Assert.Contains(result, t => t.Name == "bun test" && t.Category == TechCategory.Testing);
}

[Fact]
public void Does_not_detect_bun_test_from_unrelated_script()
{
var engine = new RuleEngine(Data);
var result = engine.Detect(Ctx(scripts: ["bun run build", "vitest"]));
Assert.DoesNotContain(result, t => t.Name == "bun test");
}

[Fact]
public void Supersedes_hides_subsumed_framework()
{
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: 759
rulesLoaded: 760
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: 759
rulesLoaded: 760
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: 759
rulesLoaded: 760
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: 759
rulesLoaded: 760
languageDefsLoaded: 815
ignoredDirectories: []
4 changes: 4 additions & 0 deletions src/Ivy.StackAnalyzer/Components/ComponentContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,8 @@ public sealed class ComponentContext

/// <summary>Environment-variable names discovered in <c>.env*</c> files.</summary>
public IReadOnlySet<string> EnvVarNames { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

/// <summary>Run-script command strings across this component's manifests (e.g. <c>package.json</c>
/// <c>scripts</c> values). Used to detect tools invoked only via a script, such as <c>bun test</c>.</summary>
public IReadOnlySet<string> Scripts { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
1 change: 1 addition & 0 deletions src/Ivy.StackAnalyzer/Components/ComponentDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ public IReadOnlyList<ComponentContext> Detect(IReadOnlyList<ClassifiedFile> file
FilePaths = filePaths,
Extensions = extensions,
EnvVarNames = ReadEnvVarNames(compFiles),
Scripts = manifests.SelectMany(m => m.Scripts).ToHashSet(StringComparer.OrdinalIgnoreCase),
});
}

Expand Down
8 changes: 8 additions & 0 deletions src/Ivy.StackAnalyzer/Detection/RuleEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ public IReadOnlyList<DetectedTechnology> Detect(ComponentContext ctx)
if (ctx.Extensions.Contains(ext)) evidence.Add($"*{ext}");
}

// Run-script commands (e.g. package.json scripts.test = "bun test")
foreach (var sr in m.ScriptsRegex)
{
var rx = GetRegex(sr);
var hit = ctx.Scripts.FirstOrDefault(s => rx.IsMatch(s));
if (hit is not null) evidence.Add($"script '{hit}'");
}

// Every facet above is a strong signal; dotenv (below) is weak.
bool strong = evidence.Count > 0;

Expand Down
4 changes: 4 additions & 0 deletions src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ public sealed record ParsedManifest
/// <summary>Workspace member globs declared here (npm/pnpm/cargo/etc.), if any.</summary>
public IReadOnlyList<string> Workspaces { get; init; } = [];

/// <summary>Run-script command strings declared here (e.g. <c>package.json</c> <c>scripts</c>
/// values like <c>"bun test"</c>). Lets the rule engine detect tools invoked only via a script.</summary>
public IReadOnlyList<string> Scripts { get; init; } = [];

/// <summary>MSBuild <c>Sdk</c> attribute for <c>*.csproj</c> / <c>*.fsproj</c>.</summary>
public string? Sdk { get; init; }

Expand Down
7 changes: 7 additions & 0 deletions src/Ivy.StackAnalyzer/Manifests/NpmParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public ParsedManifest Parse(string relativePath, string content)
{
var deps = new List<Dependency>();
var workspaces = new List<string>();
var scripts = new List<string>();
try
{
using var doc = JsonDocument.Parse(content, new JsonDocumentOptions
Expand All @@ -25,6 +26,11 @@ public ParsedManifest Parse(string relativePath, string content)
ReadDeps(root, "peerDependencies", DependencyScope.Peer, deps);
ReadDeps(root, "optionalDependencies", DependencyScope.Optional, deps);

if (root.TryGetProperty("scripts", out var sc) && sc.ValueKind == JsonValueKind.Object)
foreach (var s in sc.EnumerateObject())
if (s.Value.ValueKind == JsonValueKind.String && s.Value.GetString() is { Length: > 0 } cmd)
scripts.Add(cmd);

if (root.TryGetProperty("workspaces", out var ws))
{
if (ws.ValueKind == JsonValueKind.Array)
Expand All @@ -44,6 +50,7 @@ public ParsedManifest Parse(string relativePath, string content)
Ecosystem = "npm",
Dependencies = deps,
Workspaces = workspaces,
Scripts = scripts,
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/Ivy.StackAnalyzer/Models/DataModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ public sealed class MatchSpec
public List<string> Dotenv { get; set; } = [];
public List<string> Sdk { get; set; } = [];
public List<string> PathGlobs { get; set; } = [];

/// <summary>Regexes matched against run-script command strings (e.g. <c>package.json</c>
/// <c>scripts</c> values). Detects tools invoked only via a script, such as <c>bun test</c>.</summary>
public List<string> ScriptsRegex { get; set; } = [];
}

/// <summary>A technology detection rule as loaded from <c>detectors/*.yml</c>.</summary>
Expand Down
8 changes: 8 additions & 0 deletions src/Ivy.StackAnalyzer/data/detectors/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
- { ecosystem: docker, pattern: cypress }
filesRegex: ["cypress.config.(js|ts|mjs|cjs)"]
confidence: high
- id: bun-test
name: bun test
category: testing
match:
# bun's test runner has no dependency or config-file marker; it is wired
# through package.json scripts (e.g. "test": "bun test").
scriptsRegex: ['\bbun test\b']
confidence: high
- id: go-testing
name: Go testing
category: testing
Expand Down
Loading