-
Notifications
You must be signed in to change notification settings - Fork 25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Schema Analyzer to infer component schemas from classes #498
Merged
+1,511
−0
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
component: sdk/provider | ||
kind: Improvements | ||
body: Schema Analyzer to infer component schemas from classes | ||
time: 2025-02-21T17:13:41.55643+01:00 | ||
custom: | ||
PR: "468" |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,403 @@ | ||
// Copyright 2025, Pulumi Corporation | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.Collections.Immutable; | ||
using System.Linq; | ||
using System.Reflection; | ||
using System.Text.RegularExpressions; | ||
namespace Pulumi.Experimental.Provider | ||
{ | ||
/// <summary> | ||
/// Analyzes component resource types and generates a package schema. | ||
/// </summary> | ||
public sealed class ComponentAnalyzer | ||
{ | ||
private readonly Metadata metadata; | ||
private readonly Dictionary<string, ComplexTypeSpec> typeDefinitions = new(); | ||
|
||
private ComponentAnalyzer(Metadata metadata) | ||
{ | ||
this.metadata = metadata; | ||
} | ||
|
||
/// <summary> | ||
/// Analyzes the components in the given assembly and generates a package schema. | ||
/// </summary> | ||
/// <param name="metadata">The package metadata including name (required), version and display name (optional)</param> | ||
/// <param name="assembly">The assembly containing component resource types to analyze</param> | ||
/// <returns>A PackageSpec containing the complete schema for all components and their types</returns> | ||
public static PackageSpec GenerateSchema(Metadata metadata, Assembly assembly) | ||
{ | ||
var types = assembly.GetTypes() | ||
.Where(t => typeof(ComponentResource).IsAssignableFrom(t) && !t.IsAbstract); | ||
return GenerateSchema(metadata, types.ToArray()); | ||
} | ||
|
||
/// <summary> | ||
/// Analyzes the specified component types and generates a package schema. | ||
/// </summary> | ||
/// <param name="metadata">The package metadata including name (required), version and display name (optional)</param> | ||
/// <param name="componentTypes">The component resource types to analyze</param> | ||
/// <returns>A PackageSpec containing the complete schema for all components and their types</returns> | ||
public static PackageSpec GenerateSchema(Metadata metadata, params Type[] componentTypes) | ||
{ | ||
if (metadata?.Name == null || string.IsNullOrWhiteSpace(metadata.Name)) | ||
{ | ||
throw new ArgumentException("Package name cannot be empty or whitespace", nameof(metadata)); | ||
} | ||
|
||
if (!Regex.IsMatch(metadata.Name, "^[a-zA-Z][-a-zA-Z0-9_]*$")) | ||
{ | ||
throw new ArgumentException( | ||
"Package name must start with a letter and contain only letters, numbers, hyphens, and underscores", | ||
nameof(metadata)); | ||
} | ||
|
||
if (componentTypes.Length == 0) | ||
{ | ||
throw new ArgumentException("At least one component type must be provided"); | ||
} | ||
|
||
var analyzer = new ComponentAnalyzer(metadata); | ||
var components = new Dictionary<string, ResourceSpec>(); | ||
|
||
foreach (var type in componentTypes) | ||
{ | ||
if (!typeof(ComponentResource).IsAssignableFrom(type)) | ||
{ | ||
throw new ArgumentException($"Type {type.Name} must inherit from ComponentResource"); | ||
} | ||
components[type.Name] = analyzer.AnalyzeComponent(type); | ||
} | ||
|
||
return analyzer.GenerateSchema(metadata, components, analyzer.typeDefinitions); | ||
} | ||
|
||
private PackageSpec GenerateSchema( | ||
Metadata metadata, | ||
Dictionary<string, ResourceSpec> components, | ||
Dictionary<string, ComplexTypeSpec> typeDefinitions) | ||
{ | ||
var languages = new Dictionary<string, ImmutableSortedDictionary<string, object>>(); | ||
var settings = new Dictionary<string, object> | ||
{ | ||
["respectSchemaVersion"] = true | ||
}; | ||
foreach (var lang in new[] { "nodejs", "python", "csharp", "java", "go" }) | ||
{ | ||
languages.Add(lang, ImmutableSortedDictionary.CreateRange(settings)); | ||
} | ||
|
||
var resources = new Dictionary<string, ResourceSpec>(); | ||
foreach (var (componentName, component) in components) | ||
{ | ||
var name = $"{metadata.Name}:index:{componentName}"; | ||
resources.Add(name, component); | ||
} | ||
|
||
var types = new Dictionary<string, ComplexTypeSpec>(); | ||
foreach (var (typeName, type) in typeDefinitions) | ||
{ | ||
types.Add($"{metadata.Name}:index:{typeName}", type); | ||
} | ||
|
||
return new PackageSpec | ||
{ | ||
Name = metadata.Name, | ||
Version = metadata.Version ?? "", | ||
DisplayName = metadata.DisplayName ?? metadata.Name, | ||
Language = languages.ToImmutableSortedDictionary(), | ||
Resources = resources.ToImmutableSortedDictionary(), | ||
Types = types.ToImmutableSortedDictionary() | ||
}; | ||
} | ||
|
||
private ResourceSpec AnalyzeComponent(Type componentType) | ||
{ | ||
var argsType = GetArgsType(componentType); | ||
var inputAnalysis = AnalyzeType(argsType); | ||
var outputAnalysis = AnalyzeType(componentType); | ||
|
||
return new ResourceSpec( | ||
inputAnalysis.Properties, | ||
inputAnalysis.Required, | ||
outputAnalysis.Properties, | ||
outputAnalysis.Required); | ||
} | ||
|
||
private Type GetArgsType(Type componentType) | ||
{ | ||
return componentType.GetConstructors() | ||
.Where(c => c.GetParameters().Length == 3) // Exactly 3 parameters | ||
.Where(c => typeof(ResourceArgs).IsAssignableFrom(c.GetParameters()[1].ParameterType)) | ||
.Where(c => typeof(ComponentResourceOptions).IsAssignableFrom(c.GetParameters()[2].ParameterType)) // Third parameter must be ComponentResourceOptions | ||
.Select(c => c.GetParameters()[1].ParameterType) | ||
.FirstOrDefault() | ||
?? throw new ArgumentException( | ||
$"Component {componentType.Name} must have a constructor with exactly three parameters: " + | ||
"a string name, a parameter that extends ResourceArgs, and ComponentResourceOptions"); | ||
} | ||
|
||
private record TypeAnalysis( | ||
Dictionary<string, PropertySpec> Properties, | ||
HashSet<string> Required); | ||
|
||
private TypeAnalysis AnalyzeType(Type type) | ||
{ | ||
var properties = new Dictionary<string, PropertySpec>(); | ||
var required = new HashSet<string>(); | ||
|
||
// Analyze both fields and properties | ||
var members = type.GetMembers(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) | ||
.Where(m => m is FieldInfo or PropertyInfo); | ||
|
||
foreach (var member in members) | ||
{ | ||
var schemaName = GetSchemaPropertyName(member); | ||
properties[schemaName] = AnalyzeProperty(member); | ||
if (!IsOptionalProperty(member)) | ||
{ | ||
required.Add(schemaName); | ||
} | ||
} | ||
|
||
return new TypeAnalysis(properties, required); | ||
} | ||
|
||
private PropertySpec AnalyzeProperty(MemberInfo member) | ||
{ | ||
Type memberType = member switch | ||
{ | ||
PropertyInfo prop => prop.PropertyType, | ||
FieldInfo field => field.FieldType, | ||
_ => throw new ArgumentException($"Unsupported member type: {member.GetType()}") | ||
}; | ||
|
||
// Check if this is an input or output property | ||
var isOutput = member.GetCustomAttribute<OutputAttribute>() != null; | ||
|
||
var typeSpec = AnalyzeTypeParameter(memberType, $"{member.DeclaringType?.Name}.{member.Name}", isOutput); | ||
return new PropertySpec | ||
{ | ||
Type = typeSpec.Type, | ||
Ref = typeSpec.Ref, | ||
Plain = typeSpec.Plain, | ||
Items = typeSpec.Items, | ||
AdditionalProperties = typeSpec.AdditionalProperties | ||
}; | ||
} | ||
|
||
private bool IsOptionalProperty(MemberInfo member) | ||
{ | ||
Type memberType = member switch | ||
{ | ||
PropertyInfo prop => prop.PropertyType, | ||
FieldInfo field => field.FieldType, | ||
_ => throw new ArgumentException($"Unsupported member type: {member.GetType()}") | ||
}; | ||
|
||
// Inputs have an explicit annotation for requiredness | ||
var inputAttr = member.GetCustomAttribute<InputAttribute>(); | ||
if (inputAttr != null) | ||
{ | ||
return !inputAttr.IsRequired; | ||
} | ||
|
||
// For Output<T>, check if T is nullable | ||
if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(Output<>)) | ||
{ | ||
var outputType = memberType.GetGenericArguments()[0]; | ||
|
||
// Check if T is a nullable value type (Nullable<T>) | ||
if (outputType.IsGenericType && outputType.GetGenericTypeDefinition() == typeof(Nullable<>)) | ||
{ | ||
return true; | ||
} | ||
|
||
// For reference types, check if it's nullable | ||
if (!outputType.IsValueType) | ||
{ | ||
var nullableAttribute = member.CustomAttributes | ||
.FirstOrDefault(attr => attr.AttributeType.FullName == "System.Runtime.CompilerServices.NullableAttribute"); | ||
return nullableAttribute != null; | ||
} | ||
|
||
// For non-nullable value types in Output<T>, they are required | ||
return false; | ||
} | ||
|
||
return true; | ||
} | ||
|
||
private string GetSchemaPropertyName(MemberInfo member) | ||
{ | ||
var inputAttr = member.GetCustomAttribute<InputAttribute>(); | ||
if (inputAttr != null && !string.IsNullOrEmpty(inputAttr.Name)) | ||
{ | ||
return inputAttr.Name; | ||
} | ||
|
||
var outputAttr = member.GetCustomAttribute<OutputAttribute>(); | ||
if (outputAttr != null && !string.IsNullOrEmpty(outputAttr.Name)) | ||
{ | ||
return outputAttr.Name; | ||
} | ||
|
||
throw new ArgumentException($"Property {member.Name} has no Input or Output attribute"); | ||
} | ||
|
||
private TypeSpec AnalyzeTypeParameter(Type type, string context, bool isOutput) | ||
{ | ||
// Strings, numbers, etc. | ||
var builtinType = GetBuiltinTypeName(type); | ||
if (builtinType != null) | ||
{ | ||
return TypeSpec.CreateBuiltin(builtinType, !isOutput); | ||
} | ||
|
||
// Special types like Archive, Asset, etc. | ||
var specialTypeRef = GetSpecialTypeRef(type); | ||
if (specialTypeRef != null) | ||
{ | ||
return TypeSpec.CreateReference(specialTypeRef, !isOutput); | ||
} | ||
|
||
// Resource references are not supported yet | ||
if (typeof(CustomResource).IsAssignableFrom(type)) | ||
{ | ||
throw new ArgumentException( | ||
$"Resource references are not supported yet: found type '{type.Name}' for '{context}'"); | ||
} | ||
|
||
if (type.IsArray) | ||
{ | ||
var elementType = type.GetElementType()!; | ||
var itemSpec = AnalyzeTypeParameter(elementType, context, isOutput); | ||
return TypeSpec.CreateArray(itemSpec); | ||
} | ||
|
||
if (type.IsGenericType) | ||
{ | ||
var genericTypeDef = type.GetGenericTypeDefinition(); | ||
if (genericTypeDef == typeof(Output<>)) | ||
{ | ||
if (!isOutput) | ||
{ | ||
throw new ArgumentException($"Output<T> can only be used for output properties: {context}"); | ||
} | ||
return AnalyzeTypeParameter(type.GetGenericArguments()[0], context, true); | ||
} | ||
if (genericTypeDef == typeof(Input<>)) | ||
{ | ||
if (isOutput) | ||
{ | ||
throw new ArgumentException($"Input<T> can only be used for input properties: {context}"); | ||
} | ||
var typeSpec = AnalyzeTypeParameter(type.GetGenericArguments()[0], context, false); | ||
return typeSpec with { Plain = null }; | ||
} | ||
if (genericTypeDef == typeof(InputMap<>)) | ||
{ | ||
if (isOutput) | ||
{ | ||
throw new ArgumentException($"InputMap<T> can only be used for input properties: {context}"); | ||
} | ||
var valueType = type.GetGenericArguments()[0]; | ||
var valueSpec = AnalyzeTypeParameter(valueType, context, false); | ||
return TypeSpec.CreateDictionary(valueSpec with { Plain = null }); | ||
} | ||
if (genericTypeDef == typeof(InputList<>)) | ||
{ | ||
if (isOutput) | ||
{ | ||
throw new ArgumentException($"InputList<T> can only be used for input properties: {context}"); | ||
} | ||
var itemType = type.GetGenericArguments()[0]; | ||
var itemSpec = AnalyzeTypeParameter(itemType, context, false); | ||
return TypeSpec.CreateArray(itemSpec with { Plain = null }); | ||
} | ||
if (genericTypeDef == typeof(Nullable<>)) | ||
{ | ||
// For nullable value types, analyze the underlying type | ||
return AnalyzeTypeParameter(type.GetGenericArguments()[0], context, isOutput); | ||
} | ||
if (genericTypeDef == typeof(List<>) || genericTypeDef == typeof(IList<>)) | ||
{ | ||
var itemSpec = AnalyzeTypeParameter(type.GetGenericArguments()[0], context, isOutput); | ||
return TypeSpec.CreateArray(itemSpec); | ||
} | ||
if (genericTypeDef == typeof(Dictionary<,>) || genericTypeDef == typeof(IDictionary<,>)) | ||
{ | ||
var keyType = type.GetGenericArguments()[0]; | ||
if (keyType != typeof(string)) | ||
{ | ||
throw new ArgumentException( | ||
$"Dictionary keys must be strings, got '{keyType.Name}' for '{context}'"); | ||
} | ||
var valueSpec = AnalyzeTypeParameter(type.GetGenericArguments()[1], context, isOutput); | ||
return TypeSpec.CreateDictionary(valueSpec); | ||
} | ||
} | ||
|
||
if (!type.IsInterface && !type.IsPrimitive && type != typeof(string) && !(type.Namespace?.StartsWith("System") ?? false)) | ||
{ | ||
var typeName = GetTypeName(type); | ||
var typeRef = $"#/types/{metadata.Name}:index:{typeName}"; | ||
|
||
if (!typeDefinitions.ContainsKey(typeName)) | ||
{ | ||
typeDefinitions[typeName] = ComplexTypeSpec.CreateObject(new(), new()); | ||
var analysis = AnalyzeType(type); | ||
typeDefinitions[typeName] = ComplexTypeSpec.CreateObject(analysis.Properties, analysis.Required); | ||
} | ||
|
||
return TypeSpec.CreateReference(typeRef, !isOutput); | ||
} | ||
|
||
throw new ArgumentException($"Type '{type.FullName}' is not supported as a parameter type"); | ||
} | ||
|
||
private string GetTypeName(Type type) | ||
{ | ||
var name = type.Name; | ||
return name.EndsWith("Args") ? name[..^4] : name; | ||
} | ||
|
||
private string? GetBuiltinTypeName(Type type) | ||
{ | ||
if (type == typeof(string)) | ||
return BuiltinTypeSpec.String; | ||
if (type == typeof(int) || type == typeof(long)) | ||
return BuiltinTypeSpec.Integer; | ||
if (type == typeof(double) || type == typeof(float)) | ||
return BuiltinTypeSpec.Number; | ||
if (type == typeof(bool)) | ||
return BuiltinTypeSpec.Boolean; | ||
return null; | ||
} | ||
|
||
private string? GetSpecialTypeRef(Type type) | ||
{ | ||
if (type == typeof(Archive)) | ||
return "pulumi.json#/Archive"; | ||
if (type == typeof(Asset)) | ||
return "pulumi.json#/Asset"; | ||
return null; | ||
} | ||
} | ||
|
||
public class Metadata | ||
{ | ||
public string Name { get; } | ||
public string? Version { get; } | ||
public string? DisplayName { get; } | ||
|
||
public Metadata(string name, string? version = null, string? displayName = null) | ||
{ | ||
Name = name; | ||
Version = version; | ||
DisplayName = displayName; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
// Copyright 2025, Pulumi Corporation | ||
|
||
using System.Collections.Generic; | ||
using System.Collections.Immutable; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace Pulumi.Experimental.Provider | ||
{ | ||
public record PackageSpec | ||
{ | ||
[JsonPropertyName("name")] | ||
public string Name { get; init; } = ""; | ||
|
||
[JsonPropertyName("displayName")] | ||
public string DisplayName { get; init; } = ""; | ||
|
||
[JsonPropertyName("version")] | ||
public string Version { get; init; } = ""; | ||
|
||
[JsonPropertyName("resources")] | ||
public ImmutableSortedDictionary<string, ResourceSpec> Resources { get; init; } = | ||
ImmutableSortedDictionary<string, ResourceSpec>.Empty; | ||
|
||
[JsonPropertyName("types")] | ||
public ImmutableSortedDictionary<string, ComplexTypeSpec> Types { get; init; } = | ||
ImmutableSortedDictionary<string, ComplexTypeSpec>.Empty; | ||
|
||
[JsonPropertyName("language")] | ||
public ImmutableSortedDictionary<string, ImmutableSortedDictionary<string, object>> Language { get; init; } = | ||
ImmutableSortedDictionary<string, ImmutableSortedDictionary<string, object>>.Empty; | ||
} | ||
|
||
public record ResourceSpec : ObjectTypeSpec | ||
{ | ||
[JsonPropertyName("isComponent")] | ||
public bool IsComponent { get; init; } | ||
|
||
[JsonPropertyName("inputProperties")] | ||
public ImmutableSortedDictionary<string, PropertySpec> InputProperties { get; init; } = | ||
ImmutableSortedDictionary<string, PropertySpec>.Empty; | ||
|
||
[JsonPropertyName("requiredInputs")] | ||
public ImmutableSortedSet<string> RequiredInputs { get; init; } = | ||
ImmutableSortedSet<string>.Empty; | ||
|
||
public ResourceSpec( | ||
Dictionary<string, PropertySpec> inputProperties, | ||
HashSet<string> requiredInputs, | ||
Dictionary<string, PropertySpec> properties, | ||
HashSet<string> required) | ||
{ | ||
IsComponent = true; | ||
Type = "object"; | ||
InputProperties = inputProperties.ToImmutableSortedDictionary(); | ||
RequiredInputs = requiredInputs.ToImmutableSortedSet(); | ||
Properties = properties.ToImmutableSortedDictionary(); | ||
Required = required.ToImmutableSortedSet(); | ||
} | ||
} | ||
|
||
public record TypeSpec | ||
{ | ||
[JsonPropertyName("type")] | ||
public string? Type { get; init; } | ||
|
||
[JsonPropertyName("items")] | ||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
public TypeSpec? Items { get; init; } | ||
|
||
[JsonPropertyName("additionalProperties")] | ||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
public TypeSpec? AdditionalProperties { get; init; } | ||
|
||
[JsonPropertyName("$ref")] | ||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
public string? Ref { get; init; } | ||
|
||
[JsonPropertyName("plain")] | ||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] | ||
public bool? Plain { get; init; } | ||
|
||
public static TypeSpec CreateBuiltin(string type, bool? plain = null) => | ||
new() { Type = type, Plain = plain == true ? true : null }; | ||
|
||
public static TypeSpec CreateReference(string reference, bool? plain = null) => | ||
new() { Ref = reference, Plain = plain == true ? true : null }; | ||
|
||
public static TypeSpec CreateArray(TypeSpec items) => | ||
new() { Type = "array", Items = items }; | ||
|
||
public static TypeSpec CreateDictionary(TypeSpec additionalProperties) => | ||
new() { Type = "object", AdditionalProperties = additionalProperties }; | ||
} | ||
|
||
public record PropertySpec : TypeSpec | ||
{ | ||
public static PropertySpec String => CreateBuiltin(BuiltinTypeSpec.String); | ||
public static PropertySpec Integer => CreateBuiltin(BuiltinTypeSpec.Integer); | ||
public static PropertySpec Number => CreateBuiltin(BuiltinTypeSpec.Number); | ||
public static PropertySpec Boolean => CreateBuiltin(BuiltinTypeSpec.Boolean); | ||
|
||
public new static PropertySpec CreateBuiltin(string type, bool? plain = null) => | ||
new() { Type = type, Plain = plain == true ? true : null }; | ||
|
||
public new static PropertySpec CreateReference(string reference, bool? plain = null) => | ||
new() { Ref = reference, Plain = plain == true ? true : null }; | ||
|
||
public new static PropertySpec CreateArray(TypeSpec items) => | ||
new() { Type = "array", Items = items }; | ||
|
||
public new static PropertySpec CreateDictionary(TypeSpec additionalProperties) => | ||
new() { Type = "object", AdditionalProperties = additionalProperties }; | ||
} | ||
|
||
public record ObjectTypeSpec | ||
{ | ||
[JsonPropertyName("type")] | ||
public string Type { get; init; } = ""; | ||
|
||
[JsonPropertyName("properties")] | ||
public ImmutableSortedDictionary<string, PropertySpec> Properties { get; init; } = | ||
ImmutableSortedDictionary<string, PropertySpec>.Empty; | ||
|
||
[JsonPropertyName("required")] | ||
public ImmutableSortedSet<string> Required { get; init; } = | ||
ImmutableSortedSet<string>.Empty; | ||
} | ||
|
||
public record ComplexTypeSpec : ObjectTypeSpec | ||
{ | ||
private ComplexTypeSpec(string type) | ||
{ | ||
Type = type; | ||
} | ||
|
||
public static ComplexTypeSpec CreateObject( | ||
Dictionary<string, PropertySpec> properties, | ||
HashSet<string> required) => | ||
new("object") | ||
{ | ||
Properties = properties.ToImmutableSortedDictionary(), | ||
Required = required.ToImmutableSortedSet() | ||
}; | ||
} | ||
|
||
public static class BuiltinTypeSpec | ||
{ | ||
public const string String = "string"; | ||
public const string Integer = "integer"; | ||
public const string Number = "number"; | ||
public const string Boolean = "boolean"; | ||
public const string Object = "object"; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we evaulate this against the same regex we have in
pulumi.json
?Also micronit, I'd prefer curly braces in all if statements even if they are just one line. Not sure we have a linter or guidelines for that though.