up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Symbols Server CI / symbols-smoke (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-24 20:57:49 +02:00
parent 46c8c47d06
commit 7c39058386
92 changed files with 3549 additions and 157 deletions

View File

@@ -625,7 +625,7 @@ internal static class NodePackageCollector
var lifecycleScripts = ExtractLifecycleScripts(root);
var nodeVersions = NodeVersionDetector.Detect(context, relativeDirectory, cancellationToken);
return new NodePackage(
var package = new NodePackage(
name: name.Trim(),
version: version.Trim(),
relativePath: relativeDirectory,
@@ -644,6 +644,10 @@ internal static class NodePackageCollector
lockLocator: lockLocator,
packageSha256: packageSha256,
isYarnPnp: yarnPnpPresent);
AttachEntrypoints(package, root, relativeDirectory);
return package;
}
private static string NormalizeRelativeDirectory(LanguageAnalyzerContext context, string directory)
@@ -825,4 +829,169 @@ internal static class NodePackageCollector
=> name.Equals("preinstall", StringComparison.OrdinalIgnoreCase)
|| name.Equals("install", StringComparison.OrdinalIgnoreCase)
|| name.Equals("postinstall", StringComparison.OrdinalIgnoreCase);
private static void AttachEntrypoints(LanguageAnalyzerContext context, NodePackage package, JsonElement root, string relativeDirectory)
{
static string NormalizePath(string relativeDirectory, string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
var normalized = path.Replace('\\', '/').Trim();
while (normalized.StartsWith("./", StringComparison.Ordinal))
{
normalized = normalized[2..];
}
normalized = normalized.TrimStart('/');
if (string.IsNullOrWhiteSpace(relativeDirectory))
{
return normalized;
}
return $"{relativeDirectory.TrimEnd('/')}/{normalized}";
}
void AddEntrypoint(string? path, string conditionSet, string? binName = null, string? mainField = null, string? moduleField = null)
{
var normalized = NormalizePath(relativeDirectory, path);
if (string.IsNullOrWhiteSpace(normalized))
{
return;
}
package.AddEntrypoint(normalized, conditionSet, binName, mainField, moduleField);
}
if (root.TryGetProperty("bin", out var binElement))
{
if (binElement.ValueKind == JsonValueKind.String)
{
AddEntrypoint(binElement.GetString(), string.Empty, binName: null);
}
else if (binElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in binElement.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.String)
{
AddEntrypoint(prop.Value.GetString(), string.Empty, binName: prop.Name);
}
}
}
}
if (root.TryGetProperty("main", out var mainElement) && mainElement.ValueKind == JsonValueKind.String)
{
var mainField = mainElement.GetString();
AddEntrypoint(mainField, string.Empty, mainField: mainField);
}
if (root.TryGetProperty("module", out var moduleElement) && moduleElement.ValueKind == JsonValueKind.String)
{
var moduleField = moduleElement.GetString();
AddEntrypoint(moduleField, string.Empty, moduleField: moduleField);
}
if (root.TryGetProperty("exports", out var exportsElement))
{
foreach (var export in FlattenExports(exportsElement, prefix: string.Empty))
{
AddEntrypoint(export.Path, export.Conditions, binName: null, mainField: null, moduleField: null);
}
}
DetectShebangEntrypoints(context, package, relativeDirectory);
}
private static IEnumerable<(string Path, string Conditions)> FlattenExports(JsonElement element, string prefix)
{
switch (element.ValueKind)
{
case JsonValueKind.String:
var value = element.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
yield return (value!, prefix);
}
yield break;
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
var nextPrefix = string.IsNullOrWhiteSpace(prefix) ? property.Name : $"{prefix},{property.Name}";
foreach (var nested in FlattenExports(property.Value, nextPrefix))
{
yield return nested;
}
}
yield break;
default:
yield break;
}
}
private static void DetectShebangEntrypoints(LanguageAnalyzerContext context, NodePackage package, string relativeDirectory)
{
var baseDirectory = string.IsNullOrWhiteSpace(relativeDirectory)
? context.RootPath
: Path.Combine(context.RootPath, relativeDirectory.Replace('/', Path.DirectorySeparatorChar));
if (!Directory.Exists(baseDirectory))
{
return;
}
var candidates = Directory.EnumerateFiles(
baseDirectory,
"*.*",
new EnumerationOptions
{
RecurseSubdirectories = false,
MatchCasing = MatchCasing.CaseInsensitive,
IgnoreInaccessible = true
})
.Where(path =>
{
var ext = Path.GetExtension(path);
return string.Equals(ext, ".js", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ext, ".mjs", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ext, ".cjs", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ext, ".ts", StringComparison.OrdinalIgnoreCase);
})
.OrderBy(static p => p, StringComparer.Ordinal);
foreach (var file in candidates)
{
try
{
using var reader = File.OpenText(file);
var firstLine = reader.ReadLine();
if (string.IsNullOrWhiteSpace(firstLine))
{
continue;
}
if (!firstLine.TrimStart().StartsWith("#!", StringComparison.Ordinal))
{
continue;
}
if (!firstLine.Contains("node", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relativePath = context.GetRelativePath(file).Replace(Path.DirectorySeparatorChar, '/');
package.AddEntrypoint(relativePath, conditionSet: "shebang:node", binName: null, mainField: null, moduleField: null);
}
catch (IOException)
{
// ignore unreadable files
}
}
}
}

View File

@@ -0,0 +1,71 @@
[
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/entry-demo@1.0.0",
"purl": "pkg:npm/entry-demo@1.0.0",
"name": "entry-demo",
"version": "1.0.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"entrypoint": "bin/ed.js;cli.js;dist/feature.browser.js;dist/feature.node.js;dist/main.js;dist/module.mjs",
"entrypoint.conditions": "browser;import;node;require",
"path": "."
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "package.json"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "bin/ed.js;ed-alt"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "cli.js;entry-demo"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "dist/feature.browser.js;browser"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "dist/feature.node.js;node"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "dist/main.js;dist/main.js"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "dist/main.js;require"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "dist/module.mjs;dist/module.mjs"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "dist/module.mjs;import"
}
]
}
]

View File

@@ -0,0 +1,20 @@
{
"name": "entry-demo",
"version": "1.0.0",
"main": "dist/main.js",
"module": "dist/module.mjs",
"bin": {
"entry-demo": "cli.js",
"ed-alt": "bin/ed.js"
},
"exports": {
".": {
"import": "./dist/module.mjs",
"require": "./dist/main.js"
},
"./feature": {
"browser": "./dist/feature.browser.js",
"node": "./dist/feature.node.js"
}
}
}

View File

@@ -0,0 +1,29 @@
[
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/shebang-demo@0.1.0",
"purl": "pkg:npm/shebang-demo@0.1.0",
"name": "shebang-demo",
"version": "0.1.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"entrypoint": "run.js",
"entrypoint.conditions": "shebang:node",
"path": "."
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "package.json"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "run.js;shebang:node"
}
]
}
]

View File

@@ -0,0 +1,4 @@
{
"name": "shebang-demo",
"version": "0.1.0"
}

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
console.log('ok');

View File

@@ -100,4 +100,42 @@ public sealed class NodeLanguageAnalyzerTests
analyzers,
cancellationToken);
}
[Fact]
public async Task EntrypointsAreCapturedAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "entrypoints");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[]
{
new NodeLanguageAnalyzer()
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
[Fact]
public async Task ShebangEntrypointsAreCapturedAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "shebang");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[]
{
new NodeLanguageAnalyzer()
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
}