Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts;

using System.Collections.Generic;
using System.Text.Json.Serialization;

/// <summary>
/// Represents a package.json file.
/// https://docs.npmjs.com/cli/v10/configuring-npm/package-json.
/// </summary>
public sealed record PackageJson
{
/// <summary>
/// The name of the package.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }

/// <summary>
/// The version of the package.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; init; }

/// <summary>
/// The author of the package. Can be a string or an object with name, email, and url fields.
/// </summary>
[JsonPropertyName("author")]
[JsonConverter(typeof(PackageJsonAuthorConverter))]
public PackageJsonAuthor? Author { get; init; }

/// <summary>
/// If set to true, then npm will refuse to publish it.
/// </summary>
[JsonPropertyName("private")]
public bool? Private { get; init; }

/// <summary>
/// The engines that the package is compatible with.
/// Can be an object mapping engine names to version ranges, or occasionally an array.
/// </summary>
[JsonPropertyName("engines")]
[JsonConverter(typeof(PackageJsonEnginesConverter))]
public IDictionary<string, string>? Engines { get; init; }

/// <summary>
/// Dependencies required to run the package.
/// </summary>
[JsonPropertyName("dependencies")]
public IDictionary<string, string>? Dependencies { get; init; }

/// <summary>
/// Dependencies only needed for development and testing.
/// </summary>
[JsonPropertyName("devDependencies")]
public IDictionary<string, string>? DevDependencies { get; init; }

/// <summary>
/// Dependencies that are optional.
/// </summary>
[JsonPropertyName("optionalDependencies")]
public IDictionary<string, string>? OptionalDependencies { get; init; }

/// <summary>
/// Dependencies that will be bundled when publishing the package.
/// </summary>
[JsonPropertyName("bundledDependencies")]
public IList<string>? BundledDependencies { get; init; }

/// <summary>
/// Peer dependencies - packages that the consumer must install.
/// </summary>
[JsonPropertyName("peerDependencies")]
public IDictionary<string, string>? PeerDependencies { get; init; }

/// <summary>
/// Workspaces configuration. Can be an array of glob patterns or an object with a packages field.
/// </summary>
[JsonPropertyName("workspaces")]
[JsonConverter(typeof(PackageJsonWorkspacesConverter))]
public IList<string>? Workspaces { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts;

using System.Text.Json.Serialization;

/// <summary>
/// Represents the author field in a package.json file.
/// </summary>
public sealed record PackageJsonAuthor
{
/// <summary>
/// The name of the author.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }

/// <summary>
/// The email of the author.
/// </summary>
[JsonPropertyName("email")]
public string? Email { get; init; }

/// <summary>
/// The URL of the author.
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts;

using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

/// <summary>
/// Converts the author field in a package.json file, which can be either a string or an object.
/// String format: "Name &lt;email&gt; (url)" where email and url are optional.
/// </summary>
public sealed partial class PackageJsonAuthorConverter : JsonConverter<PackageJsonAuthor?>
{
// Matches: Name <email> (url) where email and url are optional
// Examples:
// "John Doe"
// "John Doe <john@example.com>"
// "John Doe <john@example.com> (https://example.com)"
// "John Doe (https://example.com)"
[GeneratedRegex(@"^(?<name>([^<(]+?)?)[ \t]*(?:<(?<email>([^>(]+?))>)?[ \t]*(?:\(([^)]+?)\)|$)", RegexOptions.Compiled)]
private static partial Regex AuthorStringPattern();

/// <inheritdoc />
public override PackageJsonAuthor? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}

if (reader.TokenType == JsonTokenType.String)
{
var authorString = reader.GetString();
if (string.IsNullOrWhiteSpace(authorString))
{
return null;
}

var match = AuthorStringPattern().Match(authorString);
if (!match.Success)
{
return null;
}

var name = match.Groups["name"].Value.Trim();
var email = match.Groups["email"].Value.Trim();

if (string.IsNullOrEmpty(name))
{
return null;
}

return new PackageJsonAuthor
{
Name = name,
Email = string.IsNullOrEmpty(email) ? null : email,
};
Comment on lines +45 to +57
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL is not being extracted from the author string format. The regex pattern captures the URL in a group, but the converter only extracts name and email (lines 45-56). When parsing strings like "John Doe (https://example.com)" or "John Doe <john@example.com> (https://example.com)", the URL should be extracted from the third capture group and assigned to the Url property.

Example fix:

var name = match.Groups["name"].Value.Trim();
var email = match.Groups["email"].Value.Trim();
var url = match.Groups[3].Value.Trim(); // Extract URL from third group

return new PackageJsonAuthor
{
    Name = name,
    Email = string.IsNullOrEmpty(email) ? null : email,
    Url = string.IsNullOrEmpty(url) ? null : url,
};

Note: The corresponding tests at lines 47-57 and 60-70 are also missing assertions for the URL, which is why this bug was not caught.

Copilot uses AI. Check for mistakes.
}

if (reader.TokenType == JsonTokenType.StartObject)
{
return JsonSerializer.Deserialize<PackageJsonAuthor>(ref reader, options);
}

// Skip unexpected token types
reader.Skip();
return null;
}

/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, PackageJsonAuthor? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}

JsonSerializer.Serialize(writer, value, options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts;

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

/// <summary>
/// Converts the engines field in a package.json file.
/// Engines is typically an object mapping engine names to version ranges,
/// but can occasionally be an array of strings in malformed package.json files.
/// </summary>
public sealed class PackageJsonEnginesConverter : JsonConverter<IDictionary<string, string>?>
{
/// <inheritdoc />
public override IDictionary<string, string>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}

if (reader.TokenType == JsonTokenType.StartObject)
{
var result = new Dictionary<string, string>();

while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
if (reader.TokenType == JsonTokenType.PropertyName)
{
var propertyName = reader.GetString();
reader.Read();

if (propertyName is not null && reader.TokenType == JsonTokenType.String)
{
var value = reader.GetString();
if (value is not null)
{
result[propertyName] = value;
}
}
else
{
reader.Skip();
}
}
}

return result;
}

if (reader.TokenType == JsonTokenType.StartArray)
{
// Some malformed package.json files have engines as an array
// We parse the array to check for known engine strings but return an empty dictionary
// since we can't map array values to key-value pairs
var result = new Dictionary<string, string>();

while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.String)
{
var value = reader.GetString();

// If the array contains strings like "vscode", we note it
// This matches the behavior of the original detector which checked for vscode engine
if (value is not null && value.Contains("vscode", StringComparison.OrdinalIgnoreCase))
{
result["vscode"] = value;
}
}
}

return result;
}

// Skip unexpected token types
reader.Skip();
return null;
}

/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, IDictionary<string, string>? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}

writer.WriteStartObject();
foreach (var kvp in value)
{
writer.WriteString(kvp.Key, kvp.Value);
}

writer.WriteEndObject();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts;

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

/// <summary>
/// Converts the workspaces field in a package.json file.
/// Workspaces can be:
/// - An array of glob patterns: ["packages/*"]
/// - An object with a packages field: { "packages": ["packages/*"] }.
/// </summary>
public sealed class PackageJsonWorkspacesConverter : JsonConverter<IList<string>?>
{
/// <inheritdoc />
public override IList<string>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}

if (reader.TokenType == JsonTokenType.StartArray)
{
var result = new List<string>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.String)
{
var value = reader.GetString();
if (value is not null)
{
result.Add(value);
}
}
}

return result;
}

if (reader.TokenType == JsonTokenType.StartObject)
{
// Parse object and look for "packages" field
IList<string>? packages = null;

while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
if (reader.TokenType == JsonTokenType.PropertyName)
{
var propertyName = reader.GetString();
reader.Read();

if (string.Equals(propertyName, "packages", StringComparison.OrdinalIgnoreCase) &&
reader.TokenType == JsonTokenType.StartArray)
{
packages = [];
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.String)
{
var value = reader.GetString();
if (value is not null)
{
packages.Add(value);
}
}
}
}
else
{
reader.Skip();
}
}
}

return packages;
}

// Skip unexpected token types
reader.Skip();
return null;
}

/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, IList<string>? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}

writer.WriteStartArray();
foreach (var item in value)
{
writer.WriteStringValue(item);
}

writer.WriteEndArray();
}
}
Loading
Loading