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
35 changes: 35 additions & 0 deletions src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,39 @@ public void Root_component_always_exists()
repo.Write("readme.md", "# hi\n"); // no manifests at all
Assert.Contains(Detect(repo), c => c.RelativePath == ".");
}

[Fact]
public void Compose_environment_keys_become_env_var_names()
{
// Services like Supabase/Slack are often wired via docker-compose env keys
// with no SDK dependency. Those keys must surface in EnvVarNames so the
// existing dotenv-prefixed detectors can fire.
using var repo = new TempRepo();
repo.Write("package.json", """{ "name": "root" }""")
.Write("docker-compose.yaml", """
services:
api:
image: node:20
environment:
- SUPABASE_URL=http://localhost:54321
- SLACK_WEBHOOK_URL
ports:
- "3000:3000"
worker:
image: node:20
environment:
POSTHOG_API_KEY: phc_xxx
POSTHOG_HOST: https://eu.posthog.com
""");

var root = Detect(repo).Single(c => c.RelativePath == ".");

Assert.Contains("SUPABASE_URL", root.EnvVarNames);
Assert.Contains("SLACK_WEBHOOK_URL", root.EnvVarNames);
Assert.Contains("POSTHOG_API_KEY", root.EnvVarNames);
Assert.Contains("POSTHOG_HOST", root.EnvVarNames);
// keys outside an `environment:` block (e.g. ports) must not leak in
Assert.DoesNotContain("3000", root.EnvVarNames);
Assert.DoesNotContain("ports", root.EnvVarNames);
}
}
15 changes: 15 additions & 0 deletions src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,19 @@ public void Strong_match_keeps_declared_confidence()
var react = engine.Detect(Ctx(deps: [Npm("react")])).Single(t => t.Name == "React");
Assert.Equal(Confidence.High, react.Confidence);
}

[Theory]
[InlineData("SUPABASE_URL", "Supabase")]
[InlineData("SLACK_WEBHOOK_URL", "Slack")]
[InlineData("POSTHOG_API_KEY", "PostHog")]
public void Detects_env_wired_saas_from_env_var_names(string envVar, string expectedName)
{
// These hosted services are commonly wired via env vars / compose with no SDK
// dependency; the dotenv-prefixed detectors should still surface them, but as a
// weak (Low) signal so hash/digest consumers can drop the noise.
var engine = new RuleEngine(Data);
var hit = engine.Detect(Ctx(envVars: [envVar])).FirstOrDefault(t => t.Name == expectedName);
Assert.NotNull(hit);
Assert.Equal(Confidence.Low, hit!.Confidence);
}
}
98 changes: 84 additions & 14 deletions src/Ivy.StackAnalyzer/Components/ComponentDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,23 +137,93 @@ private static IReadOnlySet<string> ReadEnvVarNames(List<ClassifiedFile> files)
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var f in files)
{
if (!f.File.FileName.StartsWith(".env", StringComparison.OrdinalIgnoreCase)) continue;
try
if (f.File.FileName.StartsWith(".env", StringComparison.OrdinalIgnoreCase))
ReadDotenvKeys(f.File.FullPath, names);
else if (IsComposeFile(f.File.FileName))
ReadComposeEnvKeys(f.File.FullPath, names);
}
return names;
}

private static void ReadDotenvKeys(string fullPath, HashSet<string> names)
{
try
{
foreach (var raw in File.ReadLines(fullPath))
{
foreach (var raw in File.ReadLines(f.File.FullPath))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith('#')) continue;
var eq = line.IndexOf('=');
if (eq <= 0) continue;
var key = line[..eq].Trim();
if (key.StartsWith("export ")) key = key["export ".Length..].Trim();
if (key.Length > 0) names.Add(key);
}
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith('#')) continue;
var eq = line.IndexOf('=');
if (eq <= 0) continue;
var key = line[..eq].Trim();
if (key.StartsWith("export ")) key = key["export ".Length..].Trim();
if (key.Length > 0) names.Add(key);
}
catch (IOException) { }
}
return names;
catch (IOException) { }
}

// Many hosted services (e.g. Supabase, Slack webhooks) are wired purely through
// docker-compose `environment:` keys with no SDK dependency. Harvesting those key
// names feeds the same `dotenv` match source as .env files, so the existing
// dotenv-prefixed detectors fire for compose-wired services too.
private static bool IsComposeFile(string fileName)
=> (fileName.StartsWith("docker-compose", StringComparison.OrdinalIgnoreCase)
|| fileName.StartsWith("compose", StringComparison.OrdinalIgnoreCase))
&& (fileName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase));

private static void ReadComposeEnvKeys(string fullPath, HashSet<string> names)
{
string[] lines;
try { lines = File.ReadAllLines(fullPath); }
catch (IOException) { return; }

bool inEnv = false;
int envIndent = -1; // indentation of the `environment:` key
foreach (var raw in lines)
{
if (raw.TrimStart().StartsWith('#')) continue;
var trimmed = raw.Trim();
if (trimmed.Length == 0) continue;
var indent = CountIndent(raw);

if (!inEnv)
{
if (IsEnvironmentHeader(trimmed)) { inEnv = true; envIndent = indent; }
continue;
}

// A line indented no further than `environment:` ends the block.
if (indent <= envIndent)
{
inEnv = IsEnvironmentHeader(trimmed);
if (inEnv) envIndent = indent;
continue;
}

// List form: - KEY=value | - KEY
// Map form: KEY: value
var entry = trimmed;
if (entry == "-") continue;
if (entry.StartsWith("- ")) entry = entry[2..].Trim();

var sep = entry.IndexOfAny(['=', ':']);
var key = (sep < 0 ? entry : entry[..sep]).Trim().Trim('"', '\'');
if (key.Length > 0) names.Add(key);
}
}

private static bool IsEnvironmentHeader(string trimmed)
=> trimmed == "environment:"
|| (trimmed.StartsWith("environment:", StringComparison.Ordinal)
&& trimmed[12..].TrimStart() is "" or "{}" or "[]");

private static int CountIndent(string line)
{
int i = 0;
while (i < line.Length && line[i] == ' ') i++;
return i;
}

private static string NearestRoot(string relativePath, List<string> sortedRootsDesc)
Expand Down
Loading