From 3b776dc63aa843d6afbee07d79a7cd9a3bc18beb Mon Sep 17 00:00:00 2001 From: nielsbosma Date: Tue, 23 Jun 2026 17:45:02 +0200 Subject: [PATCH] Detect bun test via package.json scripts matcher Adds a scriptsRegex match facet to the rule engine. package.json scripts are now parsed (NpmParser -> ParsedManifest.Scripts -> ComponentContext.Scripts) and matched against RuleDef ScriptsRegex. New 'bun test' testing detector keys off scripts.test containing 'bun test'. Closes the deferral from the testing-detectors PR; the facet is reusable for any tool invoked only via a script. rulesLoaded 759->760. --- src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs | 22 ++++++++++++++++++- .../Snapshots/django-spa.verified.yaml | 2 +- .../Snapshots/go-service.verified.yaml | 2 +- .../next-dotnet-monorepo.verified.yaml | 2 +- .../Snapshots/numpy-style-lib.verified.yaml | 2 +- .../Components/ComponentContext.cs | 4 ++++ .../Components/ComponentDetector.cs | 1 + src/Ivy.StackAnalyzer/Detection/RuleEngine.cs | 8 +++++++ .../Manifests/IManifestParser.cs | 4 ++++ src/Ivy.StackAnalyzer/Manifests/NpmParser.cs | 7 ++++++ src/Ivy.StackAnalyzer/Models/DataModels.cs | 4 ++++ .../data/detectors/testing.yml | 8 +++++++ 12 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs b/src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs index f3f5458..df8079f 100644 --- a/src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs +++ b/src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs @@ -13,7 +13,8 @@ private static ComponentContext Ctx( IEnumerable? fileNames = null, IEnumerable? sdks = null, IEnumerable? extensions = null, - IEnumerable? envVars = null) + IEnumerable? envVars = null, + IEnumerable? scripts = null) { var names = (fileNames ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase); return new ComponentContext @@ -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), }; } @@ -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() { diff --git a/src/Ivy.StackAnalyzer.Test/Snapshots/django-spa.verified.yaml b/src/Ivy.StackAnalyzer.Test/Snapshots/django-spa.verified.yaml index 25aecb9..49e4a2e 100644 --- a/src/Ivy.StackAnalyzer.Test/Snapshots/django-spa.verified.yaml +++ b/src/Ivy.StackAnalyzer.Test/Snapshots/django-spa.verified.yaml @@ -151,6 +151,6 @@ readme: in `frontend/`. metadata: analyzerVersion: 0.1.0 - rulesLoaded: 759 + rulesLoaded: 760 languageDefsLoaded: 815 ignoredDirectories: [] diff --git a/src/Ivy.StackAnalyzer.Test/Snapshots/go-service.verified.yaml b/src/Ivy.StackAnalyzer.Test/Snapshots/go-service.verified.yaml index 994ffd0..fbdf677 100644 --- a/src/Ivy.StackAnalyzer.Test/Snapshots/go-service.verified.yaml +++ b/src/Ivy.StackAnalyzer.Test/Snapshots/go-service.verified.yaml @@ -62,6 +62,6 @@ infrastructure: - Dockerfile metadata: analyzerVersion: 0.1.0 - rulesLoaded: 759 + rulesLoaded: 760 languageDefsLoaded: 815 ignoredDirectories: [] diff --git a/src/Ivy.StackAnalyzer.Test/Snapshots/next-dotnet-monorepo.verified.yaml b/src/Ivy.StackAnalyzer.Test/Snapshots/next-dotnet-monorepo.verified.yaml index 0a6d022..4344f25 100644 --- a/src/Ivy.StackAnalyzer.Test/Snapshots/next-dotnet-monorepo.verified.yaml +++ b/src/Ivy.StackAnalyzer.Test/Snapshots/next-dotnet-monorepo.verified.yaml @@ -220,6 +220,6 @@ readme: backed by Postgres. metadata: analyzerVersion: 0.1.0 - rulesLoaded: 759 + rulesLoaded: 760 languageDefsLoaded: 815 ignoredDirectories: [] diff --git a/src/Ivy.StackAnalyzer.Test/Snapshots/numpy-style-lib.verified.yaml b/src/Ivy.StackAnalyzer.Test/Snapshots/numpy-style-lib.verified.yaml index 9305559..960e6d3 100644 --- a/src/Ivy.StackAnalyzer.Test/Snapshots/numpy-style-lib.verified.yaml +++ b/src/Ivy.StackAnalyzer.Test/Snapshots/numpy-style-lib.verified.yaml @@ -53,6 +53,6 @@ technologies: infrastructure: [] metadata: analyzerVersion: 0.1.0 - rulesLoaded: 759 + rulesLoaded: 760 languageDefsLoaded: 815 ignoredDirectories: [] diff --git a/src/Ivy.StackAnalyzer/Components/ComponentContext.cs b/src/Ivy.StackAnalyzer/Components/ComponentContext.cs index de33fd6..bd88ecb 100644 --- a/src/Ivy.StackAnalyzer/Components/ComponentContext.cs +++ b/src/Ivy.StackAnalyzer/Components/ComponentContext.cs @@ -39,4 +39,8 @@ public sealed class ComponentContext /// Environment-variable names discovered in .env* files. public IReadOnlySet EnvVarNames { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// Run-script command strings across this component's manifests (e.g. package.json + /// scripts values). Used to detect tools invoked only via a script, such as bun test. + public IReadOnlySet Scripts { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); } diff --git a/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs b/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs index 66a45ec..cd331b1 100644 --- a/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs +++ b/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs @@ -111,6 +111,7 @@ public IReadOnlyList Detect(IReadOnlyList file FilePaths = filePaths, Extensions = extensions, EnvVarNames = ReadEnvVarNames(compFiles), + Scripts = manifests.SelectMany(m => m.Scripts).ToHashSet(StringComparer.OrdinalIgnoreCase), }); } diff --git a/src/Ivy.StackAnalyzer/Detection/RuleEngine.cs b/src/Ivy.StackAnalyzer/Detection/RuleEngine.cs index 5c7797d..a76e369 100644 --- a/src/Ivy.StackAnalyzer/Detection/RuleEngine.cs +++ b/src/Ivy.StackAnalyzer/Detection/RuleEngine.cs @@ -123,6 +123,14 @@ public IReadOnlyList 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; diff --git a/src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs b/src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs index 009d10a..9c80dc3 100644 --- a/src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs +++ b/src/Ivy.StackAnalyzer/Manifests/IManifestParser.cs @@ -14,6 +14,10 @@ public sealed record ParsedManifest /// Workspace member globs declared here (npm/pnpm/cargo/etc.), if any. public IReadOnlyList Workspaces { get; init; } = []; + /// Run-script command strings declared here (e.g. package.json scripts + /// values like "bun test"). Lets the rule engine detect tools invoked only via a script. + public IReadOnlyList Scripts { get; init; } = []; + /// MSBuild Sdk attribute for *.csproj / *.fsproj. public string? Sdk { get; init; } diff --git a/src/Ivy.StackAnalyzer/Manifests/NpmParser.cs b/src/Ivy.StackAnalyzer/Manifests/NpmParser.cs index ea3d790..1db65e7 100644 --- a/src/Ivy.StackAnalyzer/Manifests/NpmParser.cs +++ b/src/Ivy.StackAnalyzer/Manifests/NpmParser.cs @@ -12,6 +12,7 @@ public ParsedManifest Parse(string relativePath, string content) { var deps = new List(); var workspaces = new List(); + var scripts = new List(); try { using var doc = JsonDocument.Parse(content, new JsonDocumentOptions @@ -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) @@ -44,6 +50,7 @@ public ParsedManifest Parse(string relativePath, string content) Ecosystem = "npm", Dependencies = deps, Workspaces = workspaces, + Scripts = scripts, }; } diff --git a/src/Ivy.StackAnalyzer/Models/DataModels.cs b/src/Ivy.StackAnalyzer/Models/DataModels.cs index fbb36e2..6d95c66 100644 --- a/src/Ivy.StackAnalyzer/Models/DataModels.cs +++ b/src/Ivy.StackAnalyzer/Models/DataModels.cs @@ -44,6 +44,10 @@ public sealed class MatchSpec public List Dotenv { get; set; } = []; public List Sdk { get; set; } = []; public List PathGlobs { 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; } = []; } /// A technology detection rule as loaded from detectors/*.yml. diff --git a/src/Ivy.StackAnalyzer/data/detectors/testing.yml b/src/Ivy.StackAnalyzer/data/detectors/testing.yml index d579f00..79a72dd 100644 --- a/src/Ivy.StackAnalyzer/data/detectors/testing.yml +++ b/src/Ivy.StackAnalyzer/data/detectors/testing.yml @@ -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