diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..3fc1c2c --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + }, + "dotnet-inspect": { + "version": "0.26.0", + "commands": [ + "dotnet-inspect" + ], + "rollForward": false + }, + "purview.build": { + "version": "0.8.0", + "commands": [ + "purview-build" + ], + "rollForward": false + } + } +} diff --git a/.config/lefthook.yml b/.config/lefthook.yml new file mode 100644 index 0000000..24dd351 --- /dev/null +++ b/.config/lefthook.yml @@ -0,0 +1,9 @@ +pre-commit: + jobs: + - name: lint staged + run: just lint-check + +commit-msg: + jobs: + - name: conventional commits + run: bunx commitlint --edit {1} diff --git a/.config/purview-build.json b/.config/purview-build.json new file mode 100644 index 0000000..c0a8adb --- /dev/null +++ b/.config/purview-build.json @@ -0,0 +1,47 @@ +{ + "Build": { + "Solution": "src/XmlComments.slnx", + "TestRoot": "src/tests", + "TestPatterns": "*Tests.csproj", + "TestProjects": "*Tests*", + // Every tier: the unit tests and the integration tests, which build and run throwaway consumers against the real + // OpenAPI XML-comment generator. The integration tier is the only proof the targets work, so it is not optional. + "TestFilter": "/*/*/*/*" + }, + "PackValidation": { + // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. + "RequiredCompilerFlags": [ + "optimization=release" + ], + // The PDB ships in the symbol package, not the .nupkg, matching the other purview-dev repositories. + "RequireSymbolPackage": false, + // RequireExplicitContent defaults to true, so this is the exhaustive declaration of what the package ships: an + // entry matching none of these globs fails. + "RequiredContent": { + "purview.openapi.xmlcomments": [ + "README.md", + "buildTransitive/Purview.OpenAPI.XmlComments.props", + "buildTransitive/Purview.OpenAPI.XmlComments.targets", + "purview-logo-light.png", + "tasks/netstandard2.0/Purview.OpenAPI.XmlComments.dll", + "tasks/netstandard2.0/Purview.OpenAPI.XmlComments.xml" + ] + }, + // Every package ("*"). These hold even if a RequiredContent glob is ever widened. + "ForbiddenContent": { + "*": [ + // A build-time package: under lib/ or ref/ every consumer would compile against the task assembly and copy + // it to their output. + "lib/**", + "ref/**", + "runtimes/**", + // The host MSBuild supplies its own assemblies; a packed copy beside the task breaks type identity for + // ITask and ITaskItem when MSBuild loads it. + "**/Microsoft.Build*" + ] + } + }, + "Release": { + "Mode": "None" + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ea03058 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2714 @@ +# Purview.BuildSdk 1.2.0 - managed file, do not edit +# +# This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version +# on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is +# yours, and is never touched. +# +# To add your own rules, create a second .editorconfig in a subdirectory - `src/.editorconfig` is the +# usual choice - without `root = true`. EditorConfig applies the nearest file last, so your settings win +# over these, and nothing you write is lost when this file is updated. +# +# To stop the SDK updating this file, set one of: +# false keep this file as it is +# Never opt out of all bootstrapping +# false +# +# Deleting the first line also works: without the marker the SDK treats the file as yours. You then own +# every rule in it, including the ones a future SDK version would have fixed. +# +# See https://github.com/purview-dev/build-sdk#editorconfig for the full description. + +root = true + +# Base Configuration - General Settings, File Formatting, and Naming Conventions + +# All Files +[*] +charset = utf-8 +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent +end_of_line = lf +indent_size = 4 +indent_style = tab +insert_final_newline = true +tab_width = 4 +trim_trailing_whitespace = true +max_line_length = 120 + +# Type members +dotnet_hide_advanced_members = false +dotnet_member_insertion_location = with_other_members_of_the_same_kind +dotnet_property_generation_behavior = prefer_throwing_properties + +# Symbol search +dotnet_search_reference_assemblies = true + +# Nullability settings +dotnet_build_property.Nullable = enable + +# Per-rule severity is authoritative in this file. The SDK sets AnalysisMode/AnalysisLevel as +# MSBuild properties, and the .NET SDK ignores bulk 'dotnet_analyzer_diagnostic.*' severity +# configuration whenever those properties are present. Do not re-add a bulk entry here: it +# silently does nothing and hides which severities are actually enforced. + +# Visual Studio XML Project Files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML Configuration Files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct,xml,stylecop}] +indent_size = 2 + +# JSON Files +[*.{json,json5,webmanifest}] +indent_size = 2 + +# YAML Files +[*.{yml,yaml}] +indent_size = 2 + +# Markdown Files +[*.{md,mdx}] +trim_trailing_whitespace = false + +# Bash Files +[*.sh] +end_of_line = lf + +# Batch Files +[*.{cmd,bat}] +end_of_line = crlf + +# Web Files +[*.{htm,html,js,jsm,ts,tsx,cjs,cts,ctsx,mjs,mts,mtsx,css,sass,scss,less,pcss,svg,vue}] +indent_size = 2 +insert_final_newline = true + +# Makefiles +[Makefile] +indent_style = tab + +# .NET Style Rules - Naming Conventions +# https://docs.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/language-rules#net-style-rules + +[*.{cs,csx,cake,vb,vbx}] + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case +# NOTE: the private-field rules that used to sit here referenced a style ('camel_case_underscore') +# and a symbol group ('private_fields') that were never defined, so they never applied. The real +# private-field rules are declared in the "StyleCop Field Naming Rules" section below. +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static + +# Constants are PascalCase (field constants; local constants are locals and stay camelCase) +dotnet_naming_rule.constants_should_be_pascal_case.severity = warning +dotnet_naming_rule.constants_should_be_pascal_case.style = non_private_static_field_style +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_style.constant_style.capitalization = pascal_case +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style +dotnet_naming_rule.locals_should_be_camel_case.severity = warning + +# camel_case_style - Define the camelCase style +dotnet_naming_style.camel_case_style.capitalization = camel_case +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local + +# first_upper_style - The first character must start with an upper-case character +dotnet_naming_style.first_upper_style.capitalization = first_word_upper + +# prefix_interface_with_i_style - Interfaces must be PascalCase and first character must be 'I' +dotnet_naming_style.prefix_interface_with_i_style.required_prefix = I +dotnet_naming_style.prefix_interface_with_i_style.capitalization = pascal_case + +# prefix_type_parameters_with_t_style - Generic Type Parameters must be PascalCase and start with 'T' +dotnet_naming_style.prefix_type_parameters_with_t_style.capitalization = pascal_case +dotnet_naming_style.prefix_type_parameters_with_t_style.required_prefix = T + +# disallowed_style - Anything that has this style applied is marked as disallowed +dotnet_naming_style.disallowed_style.capitalization = pascal_case +dotnet_naming_style.disallowed_style.required_prefix = ____RULE_VIOLATION____ +dotnet_naming_style.disallowed_style.required_suffix = ____RULE_VIOLATION____ + +# internal_error_style - This style should never occur +dotnet_naming_style.internal_error_style.capitalization = pascal_case +dotnet_naming_style.internal_error_style.required_prefix = ____INTERNAL_ERROR____ +dotnet_naming_style.internal_error_style.required_suffix = ____INTERNAL_ERROR____ + +# All public/protected/protected_internal constant fields must be PascalCase +dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.symbols = public_protected_constant_fields_group +dotnet_naming_symbols.public_protected_constant_fields_group.applicable_accessibilities = public, protected, protected_internal +dotnet_naming_symbols.public_protected_constant_fields_group.applicable_kinds = field +dotnet_naming_symbols.public_protected_constant_fields_group.required_modifiers = const + +# All public/protected/protected_internal static readonly fields must be PascalCase +dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.symbols = public_protected_static_readonly_fields_group +dotnet_naming_symbols.public_protected_static_readonly_fields_group.applicable_accessibilities = public, protected, protected_internal +dotnet_naming_symbols.public_protected_static_readonly_fields_group.applicable_kinds = field +dotnet_naming_symbols.public_protected_static_readonly_fields_group.required_modifiers = static, readonly + +# No other public/protected/protected_internal fields are allowed +dotnet_naming_rule.other_public_protected_fields_disallowed_rule.severity = error +dotnet_naming_rule.other_public_protected_fields_disallowed_rule.style = disallowed_style +dotnet_naming_rule.other_public_protected_fields_disallowed_rule.symbols = other_public_protected_fields_group +dotnet_naming_symbols.other_public_protected_fields_group.applicable_accessibilities = public, protected, protected_internal +dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = field + +# StyleCop Field Naming Rules + +# All non-private constant fields must be PascalCase. Private constants are owned by +# 'private_static_fields_group' below - a field must be covered by exactly one rule, otherwise the +# same violation is reported once per matching rule. +dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field +dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const + +# All non-private static readonly fields must be PascalCase. Private static readonly fields are +# owned by 'private_static_fields_group' below (see the note on the constant rule above). +dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field +dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly + +# No non-private instance fields are allowed +dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.severity = error +dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.style = disallowed_style +dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = stylecop_fields_must_be_private_group +dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected +dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field + +# Private static fields are PascalCase: they are type-level state and are never qualified with 'this.' +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.symbols = private_static_fields_group +dotnet_naming_symbols.private_static_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields_group.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_group.required_modifiers = static + +# Private instance fields must be camelCase with a leading underscore: '_name', never 'name'. The +# prefix keeps field access unambiguous (so the noisy 'this.' qualifier is never needed) and keeps +# fields distinguishable from locals and parameters. +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.severity = warning +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.style = camel_case_underscore_style +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.symbols = private_instance_fields_group +dotnet_naming_symbols.private_instance_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_instance_fields_group.applicable_kinds = field + +# Local variables must be camelCase +dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent +dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.style = camel_case_style +dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.symbols = stylecop_local_fields_group +dotnet_naming_symbols.stylecop_local_fields_group.applicable_accessibilities = local +dotnet_naming_symbols.stylecop_local_fields_group.applicable_kinds = local + +# Sanity check - uncovered field case +dotnet_naming_rule.sanity_check_uncovered_field_case_rule.severity = error +dotnet_naming_rule.sanity_check_uncovered_field_case_rule.style = internal_error_style +dotnet_naming_rule.sanity_check_uncovered_field_case_rule.symbols = sanity_check_uncovered_field_case_group +dotnet_naming_symbols.sanity_check_uncovered_field_case_group.applicable_accessibilities = * +dotnet_naming_symbols.sanity_check_uncovered_field_case_group.applicable_kinds = field + +# All elements (namespaces, classes, enums, etc.) must be PascalCase +dotnet_naming_rule.element_rule.severity = warning +dotnet_naming_rule.element_rule.style = non_private_static_field_style +dotnet_naming_rule.element_rule.symbols = element_group +dotnet_naming_symbols.element_group.applicable_kinds = namespace, class, enum, struct, delegate, event, method, property + +# Interfaces use PascalCase and are prefixed with uppercase 'I' +dotnet_naming_rule.interface_rule.severity = warning +dotnet_naming_rule.interface_rule.style = prefix_interface_with_i_style +dotnet_naming_rule.interface_rule.symbols = interface_group +dotnet_naming_symbols.interface_group.applicable_kinds = interface + +# Generics Type Parameters use PascalCase and are prefixed with uppercase 'T' +dotnet_naming_rule.type_parameter_rule.severity = warning +dotnet_naming_rule.type_parameter_rule.style = prefix_type_parameters_with_t_style +dotnet_naming_rule.type_parameter_rule.symbols = type_parameter_group +dotnet_naming_symbols.type_parameter_group.applicable_kinds = type_parameter + +# Function parameters use camelCase +dotnet_naming_rule.parameters_rule.severity = warning +dotnet_naming_rule.parameters_rule.style = camel_case_style +dotnet_naming_rule.parameters_rule.symbols = parameters_group +dotnet_naming_symbols.parameters_group.applicable_kinds = parameter + +# Type Parameters +dotnet_naming_rule.type_parameter_naming.severity = warning +dotnet_naming_rule.type_parameter_naming.style = type_parameter_style +dotnet_naming_rule.type_parameter_naming.symbols = type_parameter_symbol +dotnet_naming_style.type_parameter_style.capitalization = pascal_case +dotnet_naming_style.type_parameter_style.required_prefix = T +dotnet_naming_symbols.type_parameter_symbol.applicable_accessibilities = * +dotnet_naming_symbols.type_parameter_symbol.applicable_kinds = type_parameter + +# Naming style shared by the private-field rules: camelCase with a required '_' prefix. +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_style.local_function_style.capitalization = pascal_case +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +# "this." and "Me." qualifiers +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Undocumented +dotnet_style_operator_placement_when_wrapping = end_of_line + +# Naming styles +# NOTE: a 'types_should_be_pascal_case' rule (symbol group 'types') and an +# 'interface_should_be_begins_with_i' rule (symbol group 'interface') used to sit here, but neither +# symbol group was ever defined, so neither rule applied. Types are covered by 'element_rule' and +# interfaces by 'interface_rule' above. + +# By default, name items with PascalCase +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = non_private_static_field_style +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members + +# pascal_case_style - Define the PascalCase style +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Symbol specifications +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.required_modifiers = * + +# Naming styles +dotnet_naming_style.begins_with_i.capitalization = pascal_case +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = + +# Simplify interpolation +dotnet_diagnostic.IDE0071.severity = warning + +# Accessibility modifiers +dotnet_diagnostic.IDE0040.severity = warning + +# Allow multiple blank lines +dotnet_diagnostic.IDE2000.severity = silent + +# Code Quality Settings +dotnet_code_quality.enable_nullable_reference_types = true +dotnet_code_quality.enableNETAnalyzers = true +dotnet_code_quality.interpolated_string_composite_format = true +dotnet_code_quality.prefer_auto_properties = false +dotnet_code_quality.prefer_const = true +dotnet_code_quality.prefer_inferred_anonymous_type_member_names = true +dotnet_code_quality.prefer_inferred_tuple_names = true +dotnet_code_quality.prefer_readonly = true +# The real modifier policy is 'dotnet_style_require_accessibility_modifiers' (declared under +# "Modifier preferences" below). The 'require_accessibility_modifiers' and +# 'require_explicit_visibility' entries that used to live here are not .NET analyzer options: +# declaring them only pretended to enforce a policy that was never applied, so they were removed. +dotnet_code_quality.require_explicit_type_arguments = true +dotnet_code_quality.require_variable_declaration_for_explicit_type = false +dotnet_code_quality_unused_parameters = all:warning +dotnet_enable_roslyn_analyzers = true +dotnet_remove_unnecessary_suppression_exclusions = none + +# RS0016: Add public types and members to the declared API +dotnet_public_api_analyzer.require_api_files = true + +# Do not use generic CodeAction.Create to create CodeAction +dotnet_diagnostic.RS0005.severity = none + +# C# Specific Settings + +[*.{cs,csx,cake}] + +# Newline options +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# C# Unnecessary code rules +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# Set the C# language version +csharp_language_version = latest + +# Prefer var for inferred expressions such as method calls. +# PDS0003 supplies the narrower explicit-type rule for object creation. +csharp_style_var_elsewhere = true:warning +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning + +# Modifier preferences +csharp_prefer_static_anonymous_function = true +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:warning +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +dotnet_style_readonly_field = true:warning +dotnet_style_require_accessibility_modifiers = omit_if_default:warning + +# Code-block preferences +csharp_prefer_braces = when_multiline:suggestion +csharp_prefer_simple_using_statement = false:silent +dotnet_style_prefer_collection_expression = true:suggestion + +# Prefer System.Threading.Lock +csharp_prefer_system_threading_lock = true:warning + +# Prefer parameter null checking +csharp_style_prefer_parameter_null_checking = true:suggestion + +# Prefer extended property pattern +csharp_style_prefer_extended_property_pattern = true:suggestion + +# Prefer UTF-8 string literals +csharp_style_prefer_utf8_string_literals = true:suggestion + +# Prefer tuple swap +csharp_style_prefer_tuple_swap = true:suggestion + +# Prefer local over anonymous function +csharp_style_prefer_local_over_anonymous_function = true:suggestion + +# Prefer unbound generic type in nameof +csharp_style_prefer_unbound_generic_type_in_nameof = true:warning + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:warning +csharp_style_deconstructed_variable_declaration = true:warning +csharp_style_implicit_object_creation_when_type_is_apparent = true:warning +csharp_style_inlined_variable_declaration = true:warning +csharp_style_pattern_local_over_anonymous_function = true:warning +csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_range_operator = true:warning +csharp_style_prefer_null_check_over_type_check = true:warning +csharp_style_throw_expression = true:warning + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:warning + +# New line preferences +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent + +# Expression-Bodied members +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_constructors = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_lambdas = true:warning +csharp_style_expression_bodied_local_functions = true:warning +csharp_style_expression_bodied_methods = true:suggestion +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_properties = true:warning + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_prefer_not_pattern = true:warning +csharp_style_prefer_pattern_matching = true:warning +csharp_style_prefer_switch_expression = true:warning +csharp_style_prefer_explicit_this = true:warning + +# "Null" checking preferences +csharp_style_conditional_delegate_call = true:warning + +# Spacing options +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrap options +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Namespace options +csharp_style_namespace_declarations = file_scoped:warning + +# Visual Basic Specific Settings + +[*.{vb}] + +visual_basic_preferred_modifier_order = Partial, Default, Private, Protected, Public, Friend, NotOverridable, Overridable, MustOverride, Overloads, Overrides, MustInherit, NotInheritable, Static, Shared, Shadows, ReadOnly, WriteOnly, Dim, Const, WithEvents, Widening, Narrowing, Custom, Async:warning +visual_basic_style_prefer_simplified_object_creation = all : suggestion +visual_basic_style_prefer_isnot_expression = true : suggestion + +# .NET Style Preferences + +[*.{cs,csx,cake,vb,vbx}] + +# Language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning + +dotnet_style_collection_initializer = true:warning +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_object_initializer = true:warning +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_compound_assignment = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning +dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:warning +dotnet_style_prefer_simplified_boolean_expressions = false:silent +dotnet_style_prefer_simplified_interpolation = true:warning + +# Expression-level preferences +dotnet_prefer_system_hash_code = true + +# Null-checking preferences +dotnet_style_coalesce_expression = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning + +[*.g.cs] +# Generated source-generator output: any diagnostics are worthless, so treat as generated code. +generated_code = true +# Missing XML comment for publicly visible type or member 'Type_or_Member' +dotnet_diagnostic.CS1591.severity = none + +# Design Rules (CA1000-CA1070) +# Design rules support adherence to the .NET Framework design guidelines + + +[*.{cs,vb}] + +# Do not declare static members on generic types +dotnet_diagnostic.CA1000.severity = suggestion + +# Types that own disposable fields should be disposable +dotnet_diagnostic.CA1001.severity = error + +# Do not expose generic lists +dotnet_diagnostic.CA1002.severity = error + +# Use generic event handler instances +dotnet_diagnostic.CA1003.severity = warning + +# Avoid excessive parameters on generic types +dotnet_diagnostic.CA1005.severity = warning + +# Enums should have zero value +dotnet_diagnostic.CA1008.severity = error + +# Collections should implement generic interface +dotnet_diagnostic.CA1010.severity = warning + +# Abstract types should not have public constructors +dotnet_diagnostic.CA1012.severity = error + +# Mark assemblies with CLSCompliantAttribute +dotnet_diagnostic.CA1014.severity = silent + +# Mark assemblies with AssemblyVersionAttribute +dotnet_diagnostic.CA1016.severity = error + +# Mark assemblies with ComVisibleAttribute +dotnet_diagnostic.CA1017.severity = silent + +# Mark attributes with AttributeUsageAttribute +dotnet_diagnostic.CA1018.severity = suggestion + +# Define accessors for attribute arguments +dotnet_diagnostic.CA1019.severity = suggestion + +# Avoid out parameters +dotnet_diagnostic.CA1021.severity = suggestion + +# Use properties where appropriate +dotnet_diagnostic.CA1024.severity = suggestion + +# Mark enums with FlagsAttribute +dotnet_diagnostic.CA1027.severity = error + +# Enum storage should be Int32 +dotnet_diagnostic.CA1028.severity = error + +# Use events where appropriate +dotnet_diagnostic.CA1030.severity = suggestion + +# Do not catch general exception types +dotnet_diagnostic.CA1031.severity = error + +# Implement standard exception constructors +# Raised to warning: the public API surface of exceptions is part of the accessibility policy and +# must not be invisible to command-line builds. +dotnet_diagnostic.CA1032.severity = warning + +# Interface methods should be callable by child types +# Raised to warning: explicit interface implementations that hide the member from derived types are +# an accessibility problem, not a style preference. +dotnet_diagnostic.CA1033.severity = warning + +# Nested types should not be visible +dotnet_diagnostic.CA1034.severity = error + +# Override methods on comparable types +dotnet_diagnostic.CA1036.severity = suggestion + +# Avoid empty interfaces +# Raised to warning: empty interfaces are unenforceable contracts and were previously invisible to +# command-line builds. +dotnet_diagnostic.CA1040.severity = warning + +# Provide ObsoleteAttribute message +dotnet_diagnostic.CA1041.severity = warning + +# Use integral or string argument for indexers +dotnet_diagnostic.CA1043.severity = suggestion + +# Properties should not be write only +dotnet_diagnostic.CA1044.severity = error + +# Do not pass types by reference +dotnet_diagnostic.CA1045.severity = warning + +# Do not overload operator equals on reference types +dotnet_diagnostic.CA1046.severity = warning + +# Do not declare protected members in sealed types +dotnet_diagnostic.CA1047.severity = error + +# Declare types in namespaces +dotnet_diagnostic.CA1050.severity = error + +# Do not declare visible instance fields +dotnet_diagnostic.CA1051.severity = error + +# Static holder types should be Static or NotInheritable +dotnet_diagnostic.CA1052.severity = warning + +# Static holder types should not have default constructors +dotnet_diagnostic.CA1053.severity = warning + +# URI parameters should not be strings +dotnet_diagnostic.CA1054.severity = warning + +# URI return values should not be strings +dotnet_diagnostic.CA1055.severity = warning + +# URI properties should not be strings +dotnet_diagnostic.CA1056.severity = warning + +# Types should not extend certain base types +dotnet_diagnostic.CA1058.severity = warning + +# Move P/Invokes to NativeMethods class +dotnet_diagnostic.CA1060.severity = warning + +# Do not hide base class methods +dotnet_diagnostic.CA1061.severity = warning + +# Validate arguments of public methods +dotnet_code_quality.CA1062.api_surface = public, protected +dotnet_diagnostic.CA1062.severity = error + +# Implement IDisposable correctly +dotnet_diagnostic.CA1063.severity = error + +# Exceptions should be public +dotnet_diagnostic.CA1064.severity = warning + +# Do not raise exceptions in unexpected locations +dotnet_diagnostic.CA1065.severity = warning + +# Implement IEquatable when overriding Equals +dotnet_diagnostic.CA1066.severity = warning + +# Override Equals when implementing IEquatable +dotnet_diagnostic.CA1067.severity = warning + +# CancellationToken parameters must come last +dotnet_diagnostic.CA1068.severity = warning + +# Enums should not have duplicate values +dotnet_diagnostic.CA1069.severity = error + +# Do not declare event fields as virtual +dotnet_diagnostic.CA1070.severity = warning + +# Documentation Rules (CA1200) +# Documentation rules support writing well-documented libraries + + +[*.{cs,vb}] + +# Avoid using cref tags with a prefix +dotnet_diagnostic.CA1200.severity = suggestion + +# Globalization Rules (CA1303-CA1311 and CA2101) +# Globalization rules support world-ready libraries and applications + + +[*.{cs,vb}] + +# Do not pass literals as localized parameters +dotnet_code_quality.CA1303.use_naming_heuristic = true +dotnet_diagnostic.CA1303.severity = none + +# Specify CultureInfo +dotnet_diagnostic.CA1304.severity = error + +# Specify IFormatProvider +dotnet_diagnostic.CA1305.severity = error + +# Specify StringComparison for clarity +dotnet_diagnostic.CA1307.severity = error + +# Normalize strings to uppercase +dotnet_diagnostic.CA1308.severity = warning + +# Use ordinal StringComparison +dotnet_diagnostic.CA1309.severity = warning + +# Specify StringComparison for correctness +dotnet_diagnostic.CA1310.severity = warning + +# Specify a culture or use an invariant version +dotnet_diagnostic.CA1311.severity = error + +# Specify marshaling for P/Invoke string arguments +dotnet_diagnostic.CA2101.severity = warning + +# Interoperability Rules (CA1400-CA1422) +# Portability rules support portability across different platforms +# Interoperability rules support interaction with COM clients + + +[*.{cs,vb}] + +# P/Invokes should not be visible +dotnet_diagnostic.CA1401.severity = error + +# Validate platform compatibility +dotnet_diagnostic.CA1416.severity = suggestion + +# Do not use OutAttribute on string parameters for P/Invokes +dotnet_diagnostic.CA1417.severity = suggestion + +# Validate platform compatibility +dotnet_diagnostic.CA1418.severity = suggestion + +# Provide a parameterless constructor that is as visible as the containing type for concrete types derived from 'System.Runtime.InteropServices.SafeHandle' +dotnet_diagnostic.CA1419.severity = suggestion + +# Property, type, or attribute requires runtime marshalling +dotnet_diagnostic.CA1420.severity = suggestion + +# Method uses runtime marshalling when DisableRuntimeMarshallingAttribute is applied +dotnet_diagnostic.CA1421.severity = warning + +# Validate platform compatibility - obsoleted APIs +dotnet_diagnostic.CA1422.severity = warning + +# Maintainability Rules (CA1501-CA1516) +# Maintainability rules support library and application maintenance + + +[*.{cs,vb}] + +# Avoid excessive inheritance +dotnet_diagnostic.CA1501.severity = warning + +# Avoid excessive complexity +dotnet_diagnostic.CA1502.severity = error + +# Avoid unmaintainable code +dotnet_diagnostic.CA1505.severity = error + +# Avoid excessive class coupling +dotnet_diagnostic.CA1506.severity = error + +# Use nameof in place of string +dotnet_diagnostic.CA1507.severity = warning + +# Avoid dead conditional code +dotnet_diagnostic.CA1508.severity = error + +# Invalid entry in code metrics configuration file +dotnet_diagnostic.CA1509.severity = error + +# Use ArgumentNullException throw helper +dotnet_diagnostic.CA1510.severity = warning + +# Use ArgumentException throw helper +dotnet_diagnostic.CA1511.severity = warning + +# Use ArgumentOutOfRangeException throw helper +dotnet_diagnostic.CA1512.severity = warning + +# Use ObjectDisposedException throw helper +dotnet_diagnostic.CA1513.severity = warning + +# Avoid redundant length argument +dotnet_diagnostic.CA1514.severity = warning + +# Consider making public types internal +# Reported as a real build warning (not a suggestion) so accessibility hygiene cannot be ignored: +# a public type in an application or test assembly should be made internal rather than suppressed. +dotnet_diagnostic.CA1515.severity = warning + +# Naming Rules (CA1700-CA1727 and IDE0130) +# Naming rules support adherence to the naming conventions of the .NET design guidelines + + +[*.{cs,vb}] + +# Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = warning + +# Do not name enum values 'Reserved' +dotnet_code_quality.CA1700.api_surface = public, protected +dotnet_diagnostic.CA1700.severity = error + +# Identifiers should not contain underscores +dotnet_code_quality.CA1707.api_surface = public, protected +dotnet_diagnostic.CA1707.severity = error + +# Identifiers should differ by more than case +dotnet_diagnostic.CA1708.severity = error + +# Identifiers should have correct suffix +dotnet_diagnostic.CA1710.severity = error + +# Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = error + +# Do not prefix enum values with type name +dotnet_diagnostic.CA1712.severity = error + +# Events should not have Before or After prefix +dotnet_diagnostic.CA1713.severity = error + +# Flags enums should have plural names +dotnet_diagnostic.CA1714.severity = error + +# Identifiers should have correct prefix +dotnet_diagnostic.CA1715.severity = error + +# Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = error + +# Only FlagsAttribute enums should have plural names +dotnet_diagnostic.CA1717.severity = error + +# Identifiers should not contain type names +dotnet_diagnostic.CA1720.severity = warning + +# Property names should not match get methods +dotnet_diagnostic.CA1721.severity = error + +# Type names should not match namespaces +dotnet_diagnostic.CA1724.severity = warning + +# Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = warning + +# Use PascalCase for named placeholders +dotnet_diagnostic.CA1727.severity = error + +# Performance Rules (CA1802-CA1877) +# Performance rules support high-performance libraries and applications + + + +[*.{cs,vb}] + +# Use Literals Where Appropriate +dotnet_diagnostic.CA1802.severity = suggestion + +# Do not initialize unnecessarily +dotnet_diagnostic.CA1805.severity = error + +# Do not ignore method results +dotnet_diagnostic.CA1806.severity = error + +# Initialize reference type static fields inline +dotnet_diagnostic.CA1810.severity = suggestion + +# Avoid uninstantiated internal classes +dotnet_diagnostic.CA1812.severity = suggestion + +# Avoid unsealed attributes +dotnet_diagnostic.CA1813.severity = suggestion + +# Prefer jagged arrays over multidimensional +dotnet_diagnostic.CA1814.severity = warning + +# Override equals and operator equals on value types +dotnet_diagnostic.CA1815.severity = warning + +# Properties should not return arrays +dotnet_diagnostic.CA1819.severity = warning +dotnet_code_quality.CA1819.api_surface = public, protected + +# Test for empty strings using string length +dotnet_diagnostic.CA1820.severity = error + +# Remove empty finalizers +dotnet_diagnostic.CA1821.severity = error + +# Mark members as static +dotnet_diagnostic.CA1822.severity = suggestion + +# Avoid unused private fields +dotnet_diagnostic.CA1823.severity = error + +# Mark assemblies with NeutralResourcesLanguageAttribute +dotnet_diagnostic.CA1824.severity = suggestion + +# Avoid zero-length array allocations +dotnet_diagnostic.CA1825.severity = suggestion + +# Use property instead of Linq Enumerable method +dotnet_diagnostic.CA1826.severity = error + +# Do not use Count/LongCount when Any can be used +dotnet_diagnostic.CA1827.severity = silent + +# Do not use CountAsync/LongCountAsync when AnyAsync can be used +dotnet_diagnostic.CA1828.severity = silent + +# Use Length/Count property instead of Enumerable.Count() method +dotnet_diagnostic.CA1829.severity = error + +# Prefer strongly-typed Append and Insert method overloads on StringBuilder +dotnet_diagnostic.CA1830.severity = warning + +# Use AsSpan instead of Range-based indexers for string when appropriate +dotnet_diagnostic.CA1831.severity = error + +# Use AsSpan or AsMemory instead of Range-based indexers for getting ReadOnlySpan or ReadOnlyMemory portion of an array * +dotnet_diagnostic.CA1832.severity = error + +# Use AsSpan or AsMemory instead of Range-based indexers for getting Span or Memory portion of an array +dotnet_diagnostic.CA1833.severity = error + +# Use StringBuilder.Append(char) for single character strings +dotnet_diagnostic.CA1834.severity = suggestion + +# Prefer the memory-based overloads of ReadAsync/WriteAsync methods in stream-based classes +dotnet_diagnostic.CA1835.severity = warning + +# Prefer IsEmpty over Count when available +dotnet_diagnostic.CA1836.severity = error + +# Use Environment.ProcessId instead of Process.GetCurrentProcess().Id +dotnet_diagnostic.CA1837.severity = suggestion + +# Avoid StringBuilder parameters for P/Invokes +dotnet_diagnostic.CA1838.severity = warning + +# Use Environment.ProcessPath instead of Process.GetCurrentProcess().MainModule.FileName +dotnet_diagnostic.CA1839.severity = error + +# Use Environment.CurrentManagedThreadId instead of Thread.CurrentThread.ManagedThreadId +dotnet_diagnostic.CA1840.severity = silent + +# Prefer Dictionary Contains methods +dotnet_diagnostic.CA1841.severity = silent + +# Do not use 'WhenAll' with a single task + * +dotnet_diagnostic.CA1842.severity = error + +# Do not use 'WaitAll' with a single task + * +dotnet_diagnostic.CA1843.severity = error + +# Provide memory-based overrides of async methods when subclassing 'Stream' * +dotnet_diagnostic.CA1844.severity = warning + +# Use span-based 'string.Concat' +dotnet_diagnostic.CA1845.severity = error + +# Prefer AsSpan over Substring +dotnet_diagnostic.CA1846.severity = error + +# CA1852 (seal internal types) is intentionally not suppressed for internal types that are exposed +# to other assemblies through InternalsVisibleTo: those types must still be sealed. The blanket +# suppression that used to sit here relied on a malformed key, so it never even applied. + +# Unsafe DataSet or DataTable in serializable type can be vulnerable to remote code execution attacks +dotnet_diagnostic.CA2352.severity = error + +# Unsafe DataSet or DataTable in serializable type +dotnet_diagnostic.CA2353.severity = error + +# Unsafe DataSet or DataTable in deserialized object graph can be vulnerable to remote code execution attack +dotnet_diagnostic.CA2354.severity = error + +# Unsafe DataSet or DataTable in deserialized object graph +dotnet_diagnostic.CA2355.severity = error + +# Unsafe DataSet or DataTable type in web deserialized object graph +dotnet_diagnostic.CA2356.severity = error + +# Ensure autogenerated class containing DataSet.ReadXml() is not used with untrusted data +dotnet_diagnostic.CA2361.severity = error + +# Unsafe DataSet or DataTable in autogenerated serializable type can be vulnerable to remote code execution attacks +dotnet_diagnostic.CA2362.severity = error + +# Review code for SQL injection vulnerabilities +dotnet_diagnostic.CA3001.severity = warning + +# Review code for XSS vulnerabilities +dotnet_diagnostic.CA3002.severity = warning + +# Review code for file path injection vulnerabilities +dotnet_diagnostic.CA3003.severity = warning + +# Review code for information disclosure vulnerabilities +dotnet_diagnostic.CA3004.severity = warning + +# Review code for LDAP injection vulnerabilities +dotnet_diagnostic.CA3005.severity = error + +# Review code for process command injection vulnerabilities +dotnet_diagnostic.CA3006.severity = error + +# Review code for open redirect vulnerabilities +dotnet_diagnostic.CA3007.severity = warning + +# Review code for XPath injection vulnerabilities +dotnet_diagnostic.CA3008.severity = error + +# Review code for XML injection vulnerabilities +dotnet_diagnostic.CA3009.severity = error + +# Review code for XAML injection vulnerabilities +dotnet_diagnostic.CA3010.severity = error + +# Review code for DLL injection vulnerabilities +dotnet_diagnostic.CA3011.severity = warning + +# Review code for regex injection vulnerabilities +dotnet_diagnostic.CA3012.severity = warning + +# Do Not Add Schema By URL +dotnet_diagnostic.CA3061.severity = warning + +# Insecure DTD processing in XML +dotnet_diagnostic.CA3075.severity = warning + +# Insecure XSLT script processing +dotnet_diagnostic.CA3076.severity = warning + +# Insecure Processing in API Design, XmlDocument and XmlTextReader +dotnet_diagnostic.CA3077.severity = warning + +# Mark Verb Handlers With Validate Antiforgery Token +dotnet_diagnostic.CA3147.severity = warning + +# Do Not Use Weak Cryptographic Algorithms +dotnet_diagnostic.CA5350.severity = warning + +# Do Not Use Broken Cryptographic Algorithms +dotnet_diagnostic.CA5351.severity = error + +# Do Not Use Unsafe Cipher Modes +dotnet_diagnostic.CA5358.severity = warning + +# Do Not Disable Certificate Validation +dotnet_diagnostic.CA5359.severity = warning + +# Do Not Call Dangerous Methods In Deserialization +dotnet_diagnostic.CA5360.severity = warning + +# Do Not Disable SChannel Use of Strong Crypto +dotnet_diagnostic.CA5361.severity = warning + +# Do Not Refer Self In Serializable Class +dotnet_diagnostic.CA5362.severity = error + +# Do Not Disable Request Validation +dotnet_diagnostic.CA5363.severity = warning + +# Do Not Use Deprecated Security Protocols +dotnet_diagnostic.CA5364.severity = error + +# Do Not Disable HTTP Header Checking +dotnet_diagnostic.CA5365.severity = warning + +# Use XmlReader For DataSet Read Xml +dotnet_diagnostic.CA5366.severity = warning + +# Do Not Serialize Types With Pointer Fields +dotnet_diagnostic.CA5367.severity = warning + +# Set ViewStateUserKey For Classes Derived From Page +dotnet_diagnostic.CA5368.severity = warning + +# Use XmlReader For Deserialize +dotnet_diagnostic.CA5369.severity = warning + +# Use XmlReader For Validating Reader +dotnet_diagnostic.CA5370.severity = warning + +# Use XmlReader For Schema Read +dotnet_diagnostic.CA5371.severity = warning + +# Use XmlReader For XPathDocument +dotnet_diagnostic.CA5372.severity = warning + +# Do not use obsolete key derivation function +dotnet_diagnostic.CA5373.severity = warning + +# Do Not Use XslTransform +dotnet_diagnostic.CA5374.severity = warning + +# Do Not Use Account Shared Access Signature +dotnet_diagnostic.CA5375.severity = warning + +# Use SharedAccessProtocol HttpsOnly +dotnet_diagnostic.CA5376.severity = warning + +# Use Container Level Access Policy +dotnet_diagnostic.CA5377.severity = warning + +# Do not disable ServicePointManagerSecurityProtocols +dotnet_diagnostic.CA5378.severity = warning + +# Do Not Use Weak Key Derivation Function Algorithm +dotnet_diagnostic.CA5379.severity = warning + +# Do Not Add Certificates To Root Store +dotnet_diagnostic.CA5380.severity = warning + +# Ensure Certificates Are Not Added To Root Store +dotnet_diagnostic.CA5381.severity = warning + +# Use Secure Cookies In ASP.Net Core +dotnet_diagnostic.CA5382.severity = warning + +# Ensure Use Secure Cookies In ASP.Net Core +dotnet_diagnostic.CA5383.severity = warning + +# Do Not Use Digital Signature Algorithm (DSA) +dotnet_diagnostic.CA5384.severity = warning + +# Use Rivest–Shamir–Adleman (RSA) Algorithm With Sufficient Key Size +dotnet_diagnostic.CA5385.severity = warning + +# Avoid hardcoding SecurityProtocolType value +dotnet_diagnostic.CA5386.severity = warning + +# Do Not Use Weak Key Derivation Function With Insufficient Iteration Count +dotnet_diagnostic.CA5387.severity = warning + +# Ensure Sufficient Iteration Count When Using Weak Key Derivation Function +dotnet_diagnostic.CA5388.severity = warning + +# Do Not Add Archive Item's Path To The Target File System Path +dotnet_diagnostic.CA5389.severity = warning + +# Do Not Hard Code Encryption Key +dotnet_diagnostic.CA5390.severity = error + +# Use antiforgery tokens in ASP.NET Core MVC controllers +dotnet_diagnostic.CA5391.severity = warning + +# Use DefaultDllImportSearchPaths attribute for P/Invokes +dotnet_diagnostic.CA5392.severity = warning + +# Do not use unsafe DllImportSearchPath value +dotnet_diagnostic.CA5393.severity = warning + +# Do not use insecure randomness +dotnet_diagnostic.CA5394.severity = warning + +# Miss HttpVerb attribute for action methods +dotnet_diagnostic.CA5395.severity = warning + +# Set HttpOnly to true for HttpCookie +dotnet_diagnostic.CA5396.severity = warning + +# Do not use deprecated SslProtocols values +dotnet_diagnostic.CA5397.severity = warning + +# Avoid hardcoded SslProtocols values +dotnet_diagnostic.CA5398.severity = warning + +# Definitely disable HttpClient certificate revocation list check +dotnet_diagnostic.CA5399.severity = warning + +# Ensure HttpClient certificate revocation list check is not disabled +dotnet_diagnostic.CA5400.severity = warning + +# Do not use CreateEncryptor with non-default IV +dotnet_diagnostic.CA5401.severity = suggestion + +# Use CreateEncryptor with the default IV +dotnet_diagnostic.CA5402.severity = warning + +# Do not hard-code certificate +dotnet_diagnostic.CA5403.severity = error + +# Do not disable token validation checks +dotnet_diagnostic.CA5404.severity = error + +# Do not always skip token validation in delegates +dotnet_diagnostic.CA5405.severity = error + +dotnet_diagnostic.SEC001.severity = error + +# SingleFile Rules (IL3000-IL3005) +# Single-file rules support single-file applications + + +[*.{cs,vb}] + +# Avoid using accessing Assembly file path when publishing as a single-file +dotnet_diagnostic.IL3000.severity = error + +# Avoid accessing Assembly file path when publishing as a single file +dotnet_diagnostic.IL3001.severity = error + +# Avoid calling members annotated with 'RequiresAssemblyFilesAttribute' when publishing as a single file +dotnet_diagnostic.IL3002.severity = error + +# RequiresAssemblyFilesAttribute annotations must match across all interface implementations or overrides +dotnet_diagnostic.IL3003.severity = error + +# RequiresAssemblyFilesAttribute cannot be placed directly on application entry point +dotnet_diagnostic.IL3005.severity = error + +# Style Rules (IDE0001-IDE0380) +# Style rules support consistent code style in your codebase + + +[*.{cs,vb}] + +# Simplify name +dotnet_diagnostic.IDE0001.severity = warning + +# Simplify member access +dotnet_diagnostic.IDE0002.severity = warning + +# this and Me preferences +dotnet_diagnostic.IDE0003.severity = suggestion +dotnet_diagnostic.IDE0009.severity = suggestion + +# Remove unnecessary cast +dotnet_diagnostic.IDE0004.severity = error + +# Remove unnecessary import +dotnet_diagnostic.IDE0005.severity = warning + +# Add missing cases to switch statement +dotnet_diagnostic.IDE0010.severity = warning + +# Add braces +dotnet_diagnostic.IDE0011.severity = silent + +# Use throw expression +dotnet_diagnostic.IDE0016.severity = warning + +# Use object initializers +dotnet_diagnostic.IDE0017.severity = suggestion + +# Inline variable declaration +dotnet_diagnostic.IDE0018.severity = suggestion + +# Use pattern matching to avoid 'as' followed by a 'null' check +dotnet_diagnostic.IDE0019.severity = warning + +# Use pattern matching to avoid 'is' check followed by a cast +dotnet_diagnostic.IDE0020.severity = warning + +# Use expression body for constructors +dotnet_diagnostic.IDE0021.severity = silent + +# Use expression body for methods +dotnet_diagnostic.IDE0022.severity = silent + +# Use expression body for operators +dotnet_diagnostic.IDE0023.severity = silent +dotnet_diagnostic.IDE0024.severity = silent + +# Use expression body for properties +dotnet_diagnostic.IDE0025.severity = silent + +# Use expression body for indexers +dotnet_diagnostic.IDE0026.severity = suggestion + +# Use expression body for accessors +dotnet_diagnostic.IDE0027.severity = silent + +# Use collection initializers +dotnet_diagnostic.IDE0028.severity = suggestion + +# Use coalesce expression +dotnet_diagnostic.IDE0029.severity = suggestion + +# Use coalesce expression (nullable types) +dotnet_diagnostic.IDE0030.severity = suggestion + +# Use null propagation +dotnet_diagnostic.IDE0031.severity = suggestion + +# Use auto property +dotnet_diagnostic.IDE0032.severity = suggestion + +# Use explicitly provided tuple name +dotnet_diagnostic.IDE0033.severity = suggestion + +# Simplify 'default' expression +dotnet_diagnostic.IDE0034.severity = suggestion + +# Remove unreachable code +dotnet_diagnostic.IDE0035.severity = error + +# Order modifiers +dotnet_diagnostic.IDE0036.severity = warning + +# Use inferred member name +dotnet_diagnostic.IDE0037.severity = suggestion + +# Use pattern matching to avoid is check followed by a cast (without variable) +dotnet_diagnostic.IDE0038.severity = warning + +# Use local function instead of lambda +dotnet_diagnostic.IDE0039.severity = suggestion + +# IDE0040 is declared exactly once, in the "Accessibility modifiers" section above. A second entry +# here used to override that value (later entries win), which made the effective modifier policy +# ambiguous and let repos believe the rule had been turned off. + +# Use is null check +dotnet_diagnostic.IDE0041.severity = suggestion + +# Deconstruct variable declaration +dotnet_diagnostic.IDE0042.severity = suggestion + +# Format string contains invalid placeholder +dotnet_diagnostic.IDE0043.severity = warning + +# Add readonly modifier +dotnet_diagnostic.IDE0044.severity = warning + +# Use conditional expression for assignment +dotnet_diagnostic.IDE0045.severity = suggestion + +# Use conditional expression for return +dotnet_diagnostic.IDE0046.severity = suggestion + +# Parentheses preferences +dotnet_diagnostic.IDE0047.severity = warning +dotnet_diagnostic.IDE0048.severity = warning + +# Use language keywords instead of framework type names for type references +dotnet_diagnostic.IDE0049.severity = warning + +# Convert anonymous type to tuple +dotnet_diagnostic.IDE0050.severity = suggestion + +# Remove unused private member +dotnet_diagnostic.IDE0051.severity = warning + +# Remove unread private member +dotnet_diagnostic.IDE0052.severity = error + +# Use expression body for lambdas +dotnet_diagnostic.IDE0053.severity = suggestion + +# Use compound assignment +dotnet_diagnostic.IDE0054.severity = suggestion + +# Fix formatting +dotnet_diagnostic.IDE0055.severity = suggestion + +# Use index operator +dotnet_diagnostic.IDE0056.severity = suggestion + +# Use range operator +dotnet_diagnostic.IDE0057.severity = suggestion + +# Remove unnecessary expression value +dotnet_diagnostic.IDE0058.severity = silent + +# Remove unnecessary value assignment +dotnet_diagnostic.IDE0059.severity = error + +# Remove unused parameter +dotnet_diagnostic.IDE0060.severity = error + +# Use expression body for local functions +dotnet_diagnostic.IDE0061.severity = suggestion + +# Make local function static +dotnet_diagnostic.IDE0062.severity = suggestion + +# Use simple 'using' statement +dotnet_diagnostic.IDE0063.severity = suggestion + +# Make struct fields writable +dotnet_diagnostic.IDE0064.severity = suggestion + +# 'using' directive placement +dotnet_diagnostic.IDE0065.severity = error + +# Use switch expression +dotnet_diagnostic.IDE0066.severity = warning + +# Use 'System.HashCode.Combine' +dotnet_diagnostic.IDE0070.severity = warning + +# Add missing cases to switch expression +dotnet_diagnostic.IDE0072.severity = suggestion + +# Require file header +dotnet_diagnostic.IDE0073.severity = warning + +# Use compound assignment +dotnet_diagnostic.IDE0074.severity = suggestion + +# Simplify conditional expression +dotnet_diagnostic.IDE0075.severity = suggestion + +# Remove invalid global 'SuppressMessageAttribute' +dotnet_diagnostic.IDE0076.severity = silent + +# Avoid legacy format target in global 'SuppressMessageAttribute' +dotnet_diagnostic.IDE0077.severity = warning + +# Use pattern matching +dotnet_diagnostic.IDE0078.severity = warning + +# Remove unnecessary suppression +dotnet_diagnostic.IDE0079.severity = error + +# Remove unnecessary suppression operator +dotnet_diagnostic.IDE0080.severity = suggestion + +# Remove ByVal +dotnet_diagnostic.IDE0081.severity = error + +# Convert typeof to nameof +dotnet_diagnostic.IDE0082.severity = warning + +# Use pattern matching (not operator) +dotnet_diagnostic.IDE0083.severity = warning + +# Use pattern matching (IsNot operator) +dotnet_diagnostic.IDE0084.severity = suggestion + +# Simplify new expression +dotnet_diagnostic.IDE0090.severity = warning + +# Remove unnecessary equality operator +dotnet_diagnostic.IDE0100.severity = silent + +# Use conditional delegate call +dotnet_diagnostic.IDE0105.severity = warning + +# Remove unnecessary discard +dotnet_diagnostic.IDE0110.severity = warning + +# Simplify LINQ expression +dotnet_diagnostic.IDE0120.severity = warning + +# Simplify LINQ type check and cast +dotnet_diagnostic.IDE0121.severity = warning + +# Simplify object creation +dotnet_diagnostic.IDE0140.severity = suggestion + +# Prefer 'null' check over type check +dotnet_diagnostic.IDE0150.severity = warning + +# Use block-scoped namespace +dotnet_diagnostic.IDE0160.severity = suggestion + +# Use file-scoped namespace +dotnet_diagnostic.IDE0161.severity = suggestion + +# Simplify property pattern +dotnet_diagnostic.IDE0170.severity = warning + +# Use tuple to swap values +dotnet_diagnostic.IDE0180.severity = suggestion + +# Unnecessary lambda expression +dotnet_diagnostic.IDE0200.severity = warning + +# Convert to top-level statements +dotnet_diagnostic.IDE0210.severity = warning + +# Convert to 'Program.Main' style program +dotnet_diagnostic.IDE0211.severity = suggestion + +# Add explicit cast in foreach loop +dotnet_diagnostic.IDE0220.severity = error + +# Use UTF-8 string literal +dotnet_diagnostic.IDE0230.severity = warning + +# Nullable directive is redundant +dotnet_diagnostic.IDE0240.severity = error + +# Nullable directive is unnecessary +dotnet_diagnostic.IDE0241.severity = error + +# Struct can be made 'readonly' +dotnet_diagnostic.IDE0250.severity = warning + +# Member can be made 'readonly' +dotnet_diagnostic.IDE0251.severity = warning + +# Use pattern matching +dotnet_diagnostic.IDE0260.severity = warning + +# Null check can be simplified +dotnet_diagnostic.IDE0270.severity = warning + +# Use 'nameof' +dotnet_diagnostic.IDE0280.severity = error + +# Use primary constructor +dotnet_diagnostic.IDE0290.severity = suggestion + +# Use collection expression for array +dotnet_diagnostic.IDE0300.severity = warning + +# Use collection expression for empty +dotnet_diagnostic.IDE0301.severity = suggestion + +# Use collection expression for stackalloc +dotnet_diagnostic.IDE0302.severity = warning + +# Use collection expression for Create() +dotnet_diagnostic.IDE0303.severity = warning + +# Use collection expression for builder +dotnet_diagnostic.IDE0304.severity = warning + +# Use collection expression for fluent +dotnet_diagnostic.IDE0305.severity = warning + +# Use collection expression for new +dotnet_diagnostic.IDE0306.severity = warning + +# Make anonymous function static +dotnet_diagnostic.IDE0320.severity = warning + +# Prefer 'System.Threading.Lock' +dotnet_diagnostic.IDE0330.severity = warning + +# Use unbound generic type +dotnet_diagnostic.IDE0340.severity = warning + +# Use implicitly typed lambda +dotnet_diagnostic.IDE0350.severity = warning + +# Simplify property accessor +dotnet_diagnostic.IDE0360.severity = suggestion + +# Remove unnecessary `unsafe` modifier +dotnet_diagnostic.IDE0380.severity = warning + +# Remove unnecessary suppression (null-forgiving operator) +dotnet_diagnostic.IDE0370.severity = warning + +# Naming rule violation - must stay visible: the field-naming policy is only enforced when this +# diagnostic is reported, because naming rules without their own severity inherit this one. +dotnet_diagnostic.IDE1006.severity = warning + +# Embedded statements must be on their own line +dotnet_diagnostic.IDE2001.severity = warning + +# Consecutive braces must not have blank line between them +dotnet_diagnostic.IDE2002.severity = warning + +# Blank line required between block and subsequent statement +dotnet_diagnostic.IDE2003.severity = warning + +# Blank line not allowed after constructor initializer colon +dotnet_diagnostic.IDE2004.severity = warning + +# Blank line not allowed after conditional expression token +dotnet_diagnostic.IDE2005.severity = warning + +# Blank line not allowed after arrow expression clause token +dotnet_diagnostic.IDE2006.severity = warning + +# C# Style Rules - var preferences + +[*.{cs,csx,cake}] + +dotnet_diagnostic.IDE0007.severity = warning +dotnet_diagnostic.IDE0008.severity = none +dotnet_diagnostic.PDS0003.severity = warning +dotnet_diagnostic.PDS0004.severity = warning + +# Usage Rules (CA1801, CA1816, CA2200-CA2267) +# Usage rules support proper usage of .NET + + +[*.{cs,vb}] + +# Review unused parameters +dotnet_diagnostic.CA1801.severity = warning + +# Call GC.SuppressFinalize correctly +dotnet_diagnostic.CA1816.severity = error + +# Rethrow to preserve stack details +dotnet_diagnostic.CA2200.severity = error + +# Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = error + +# Initialize value type static fields inline +dotnet_diagnostic.CA2207.severity = warning + +# Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = error + +# Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = error + +# Disposable fields should be disposed +dotnet_diagnostic.CA2213.severity = error + +# Do not call overridable methods in constructors +dotnet_diagnostic.CA2214.severity = warning + +# Dispose methods should call base class dispose +dotnet_diagnostic.CA2215.severity = error + +# Disposable types should declare finalizer +dotnet_diagnostic.CA2216.severity = warning + +# Do not mark enums with FlagsAttribute +dotnet_diagnostic.CA2217.severity = error + +# Override GetHashCode on overriding Equals +dotnet_diagnostic.CA2218.severity = warning + +# Do not raise exceptions in exception clauses +dotnet_diagnostic.CA2219.severity = error + +# Override Equals on overloading operator equals +dotnet_diagnostic.CA2224.severity = warning + +# Operator overloads have named alternates +dotnet_diagnostic.CA2225.severity = warning + +# Operators should have symmetrical overloads +dotnet_diagnostic.CA2226.severity = warning + +# Collection properties should be read only +dotnet_diagnostic.CA2227.severity = error + +# Implement serialization constructors +dotnet_diagnostic.CA2229.severity = warning + +# Overload operator equals on overriding ValueType.Equals +dotnet_diagnostic.CA2231.severity = warning + +# Pass System.Uri objects instead of strings +dotnet_diagnostic.CA2234.severity = warning + +# Mark all non-serializable fields +dotnet_diagnostic.CA2235.severity = suggestion + +# Mark ISerializable types with SerializableAttribute +dotnet_diagnostic.CA2237.severity = warning + +# Provide correct arguments to formatting methods +dotnet_diagnostic.CA2241.severity = warning + +# Test for NaN correctly +dotnet_diagnostic.CA2242.severity = warning + +# Attribute string literals should parse correctly +dotnet_diagnostic.CA2243.severity = warning + +# Do not duplicate indexed element initializations +dotnet_diagnostic.CA2244.severity = warning + +# Do not assign a property to itself +dotnet_diagnostic.CA2245.severity = error + +# Do not assign a symbol and its member in the same statement +dotnet_diagnostic.CA2246.severity = warning + +# Argument passed to TaskCompletionSource constructor should be TaskCreationOptions enum instead of TaskContinuationOptions enum +dotnet_diagnostic.CA2247.severity = warning + +# Provide correct enum argument to Enum.HasFlag +dotnet_diagnostic.CA2248.severity = warning + +# Consider using String.Contains instead of String.IndexOf +dotnet_diagnostic.CA2249.severity = error + +# Use ThrowIfCancellationRequested +dotnet_diagnostic.CA2250.severity = warning + +# Use String.Equals over String.Compare +dotnet_diagnostic.CA2251.severity = warning + +# Opt in to preview features +dotnet_diagnostic.CA2252.severity = suggestion + +# Named placeholders should not be numeric values +dotnet_diagnostic.CA2253.severity = warning + +# Template should be a static expression +dotnet_diagnostic.CA2254.severity = warning + +# The ModuleInitializer attribute should not be used in libraries +dotnet_diagnostic.CA2255.severity = warning + +# All members declared in parent interfaces must have an implementation in a DynamicInterfaceCastableImplementation-attributed interface +dotnet_diagnostic.CA2256.severity = warning + +# Members defined on an interface with 'DynamicInterfaceCastableImplementationAttribute' should be 'static' +dotnet_diagnostic.CA2257.severity = warning + +# Providing a 'DynamicInterfaceCastableImplementation' interface in Visual Basic is unsupported +dotnet_diagnostic.CA2258.severity = warning + +# Ensure ThreadStatic is only used with static fields +dotnet_diagnostic.CA2259.severity = error + +# Implement generic math interfaces correctly +dotnet_diagnostic.CA2260.severity = warning + +# Do not use ConfigureAwaitOptions.SuppressThrowing with Task +dotnet_diagnostic.CA2261.severity = warning + +# Set 'MaxResponseHeadersLength' properly +dotnet_diagnostic.CA2262.severity = warning + +# Do not pass a non-nullable value to 'ArgumentNullException.ThrowIfNull' +dotnet_diagnostic.CA2264.severity = warning + +# Do not compare Span to null or default +dotnet_diagnostic.CA2265.severity = error + +# Use correct type parameter +dotnet_diagnostic.CA2266.severity = warning + +# Use correct type parameter for 'IAsyncEnumerable.ConfigureAwait' +dotnet_diagnostic.CA2267.severity = warning + +# External Analyzer Rules +# Rules from StyleCop, Async, Dispose, Regex, JSON, MSTest, and other third-party analyzers + + + +[*.{cs,vb}] + +# StyleCop Analyzer Rules (SA) + +# A violation of this rule occurs when a compilation (project) contains files with DocumentationMode set to None +dotnet_diagnostic.SA0001.severity = warning + +# The spacing around a C# keyword is incorrect +dotnet_diagnostic.SA1000.severity = warning + +# DoNotPrefixCallsWithBaseUnlessLocalImplementationExists +dotnet_diagnostic.SA1100.severity = warning + +# StatementMustNotUseUnnecessaryParenthesis +dotnet_diagnostic.SA1119.severity = suggestion + +# CommentsMustContainText +dotnet_diagnostic.SA1120.severity = warning + +# DoNotUseRegions +dotnet_diagnostic.SA1124.severity = warning + +# UsingDirectivesMustBePlacedCorrectly +dotnet_diagnostic.SA1200.severity = warning + +# ElementsMustAppearInTheCorrectOrder +dotnet_diagnostic.SA1201.severity = warning + +# ElementsMustBeOrderedByAccess +dotnet_diagnostic.SA1202.severity = warning + +# ElementMustBeginWithUpperCaseLetter +dotnet_diagnostic.SA1300.severity = error + +# InterfaceNamesMustBeginWithI +dotnet_diagnostic.SA1302.severity = error + +# ConstFieldNamesMustBeginWithUpperCaseLetter +dotnet_diagnostic.SA1303.severity = error + +# FieldNamesMustNotUseHungarianNotation +dotnet_diagnostic.SA1305.severity = warning + +# VariableNamesMustNotBePrefixed +dotnet_diagnostic.SA1308.severity = error + +# A field name in C# begins with an underscore +dotnet_diagnostic.SA1309.severity = silent + +# VariableNamesMustBeginWithLowerCaseLetter +dotnet_diagnostic.SA1312.severity = warning + +# ParameterNamesMustBeginWithLowerCaseLetter +dotnet_diagnostic.SA1313.severity = error + +# TupleElementNamesShouldUseCorrectCasing +dotnet_diagnostic.SA1316.severity = warning + +# AccessModifierMustBeDeclared +dotnet_diagnostic.SA1400.severity = warning + +# FieldsMustBePrivate +dotnet_diagnostic.SA1401.severity = error + +# FileMayOnlyContainASingleNamespace +dotnet_diagnostic.SA1403.severity = error + +# A Code Analysis SuppressMessage attribute does not include a justification +dotnet_diagnostic.SA1404.severity = error + +# DebugAssertMustProvideMessageText +dotnet_diagnostic.SA1405.severity = warning + +# DebugFailMustProvideMessageText +dotnet_diagnostic.SA1406.severity = warning + +# RemoveUnnecessaryCode +dotnet_diagnostic.SA1409.severity = error + +# BracesForMultiLineStatementsMustNotShareLine +dotnet_diagnostic.SA1500.severity = error + +# ElementMustNotBeOnSingleLine +dotnet_diagnostic.SA1502.severity = error + +# BracesMustNotBeOmitted +dotnet_diagnostic.SA1503.severity = error + +# ClosingBraceMustBeFollowedByBlankLine +dotnet_diagnostic.SA1513.severity = warning + +# UseBracesConsistently +dotnet_diagnostic.SA1520.severity = error + +# File header copyright text should match +dotnet_diagnostic.SA1636.severity = none + +# ElementDocumentationMustBeSpelledCorrectly +dotnet_diagnostic.SA1650.severity = warning + +# Dispose Analyzer Rules (IDISP) + +# Dispose created +dotnet_diagnostic.IDISP001.severity = error + +# Dispose member +dotnet_diagnostic.IDISP002.severity = error + +# Dispose previous before re-assigning +dotnet_diagnostic.IDISP003.severity = error + +# Don't ignore created IDisposable +dotnet_diagnostic.IDISP004.severity = error + +# Return type should indicate that the value should be disposed +dotnet_diagnostic.IDISP005.severity = error + +# Implement IDisposable +dotnet_diagnostic.IDISP006.severity = error + +# Don't dispose injected +dotnet_diagnostic.IDISP007.severity = error + +# Don't assign member with injected and created disposables +dotnet_diagnostic.IDISP008.severity = error + +# Add IDisposable interface +dotnet_diagnostic.IDISP009.severity = error + +# Call base.Dispose(disposing) +dotnet_diagnostic.IDISP010.severity = error + +# Don't return disposed instance +dotnet_diagnostic.IDISP011.severity = warning + +# Property should not return created disposable +dotnet_diagnostic.IDISP012.severity = error + +# Await in using +dotnet_diagnostic.IDISP013.severity = error + +# Use a single instance of HttpClient +dotnet_diagnostic.IDISP014.severity = warning + +# Member should not return created and cached instance +dotnet_diagnostic.IDISP015.severity = error + +# Don't use disposed instance +dotnet_diagnostic.IDISP016.severity = error + +# Prefer using +dotnet_diagnostic.IDISP017.severity = error + +# Inline variable declaration +dotnet_diagnostic.IDISP018.severity = error + +# Call SuppressFinalize +dotnet_diagnostic.IDISP019.severity = error + +# Use pattern matching to avoid is check followed by a cast (with variable) +dotnet_diagnostic.IDISP020.severity = error + +# Call this.Dispose(true) +dotnet_diagnostic.IDISP021.severity = error + +# Call this.Dispose(false) +dotnet_diagnostic.IDISP022.severity = error + +# Don't use reference types in finalizer context +dotnet_diagnostic.IDISP023.severity = error + +# Don't call GC.SuppressFinalize(this) when the type is sealed and has no finalizer +dotnet_diagnostic.IDISP024.severity = error + +# Class with no virtual dispose method should be sealed +dotnet_diagnostic.IDISP025.severity = error + +# Class with no virtual DisposeAsyncCore method should be sealed +dotnet_diagnostic.IDISP026.severity = error + +# Async Analyzer Rules (ASYNC, VSTHRD, RCS) + +# Asynchronous method names should end with Async +dotnet_diagnostic.ASYNC0001.severity = error + +# Non asynchronous method names should not end with Async +dotnet_diagnostic.ASYNC0002.severity = error + +# Avoid void returning asynchronous method +dotnet_diagnostic.ASYNC0003.severity = warning + +# Use ConfigureAwait(false) on await expression +dotnet_diagnostic.ASYNC0004.severity = error + +# Do not use blocking call (make method async) +dotnet_diagnostic.MA0045.severity = none + +# Call 'ConfigureAwait(false)' +dotnet_diagnostic.RCS1090.severity = error + +# Return completed task instead of returning null +dotnet_diagnostic.RCS1210.severity = error + +# AsyncifyInvocation: Use Task Async +dotnet_diagnostic.AsyncifyInvocation.severity = error + +# AsyncifyVariable: Use Task Async +dotnet_diagnostic.AsyncifyVariable.severity = error + +# Avoid legacy thread switching methods +dotnet_diagnostic.VSTHRD001.severity = error + +# Avoid problematic synchronous waits +dotnet_diagnostic.VSTHRD002.severity = error + +# Avoid awaiting foreign Tasks +dotnet_diagnostic.VSTHRD003.severity = error + +# Await SwitchToMainThreadAsync +dotnet_diagnostic.VSTHRD004.severity = error + +# Invoke single-threaded types on Main thread +dotnet_diagnostic.VSTHRD010.severity = error + +# Use AsyncLazy +dotnet_diagnostic.VSTHRD011.severity = error + +# Provide JoinableTaskFactory where allowed +dotnet_diagnostic.VSTHRD012.severity = error + +# Avoid async void methods +dotnet_diagnostic.VSTHRD100.severity = error + +# Avoid unsupported async delegates +dotnet_diagnostic.VSTHRD101.severity = error + +# Implement internal logic asynchronously +dotnet_diagnostic.VSTHRD102.severity = error + +# Call async methods when in an async method +dotnet_diagnostic.VSTHRD103.severity = error + +# Offer async option +dotnet_diagnostic.VSTHRD104.severity = warning + +# Avoid method overloads that assume TaskScheduler.Current +dotnet_diagnostic.VSTHRD105.severity = warning + +# Use InvokeAsync to raise async events +dotnet_diagnostic.VSTHRD106.severity = error + +# Await Task within using expression +dotnet_diagnostic.VSTHRD107.severity = error + +# Assert thread affinity unconditionally +dotnet_diagnostic.VSTHRD108.severity = error + +# Switch instead of assert in async methods +dotnet_diagnostic.VSTHRD109.severity = error + +# Observe result of async calls +dotnet_diagnostic.VSTHRD110.severity = error + +# Use .ConfigureAwait(bool) +dotnet_diagnostic.VSTHRD111.severity = error + +# Implement System.IAsyncDisposable +dotnet_diagnostic.VSTHRD112.severity = error + +# Check for System.IAsyncDisposable +dotnet_diagnostic.VSTHRD113.severity = error + +# Avoid returning a null Task +dotnet_diagnostic.VSTHRD114.severity = error + +# Use "Async" suffix for async methods +dotnet_diagnostic.VSTHRD200.severity = error + +# Asynchronous method name should end with 'Async' +dotnet_diagnostic.RCS1046.severity = error + +# Non-asynchronous method name should not end with 'Async' +dotnet_diagnostic.RCS1047.severity = error + +# Class Analyzer Rules + +# Seal Class +dotnet_diagnostic.CLASS0001.severity = warning + +# Enum Analyzer Rules + +# Default switch label +dotnet_diagnostic.ENUM0001.severity = error + +# Merge switch sections +dotnet_diagnostic.ENUM0002.severity = warning + +# Populate switch +dotnet_diagnostic.ENUM0003.severity = warning + +# Regex Analyzer Rules (RE) + +# Invalid regex pattern +dotnet_diagnostic.RE0001.severity = warning + +# JSON Analyzer Rules + +# Invalid JSON pattern +dotnet_diagnostic.JSON001.severity = suggestion + +# Probable JSON string detected +dotnet_diagnostic.JSON002.severity = suggestion + +# Return Analyzer Rules + +# Do not return null +dotnet_diagnostic.RETURN0001.severity = warning + +# Roslynator Analyzer Rules (RCS) + +# Simplify boolean comparison +dotnet_diagnostic.RCS1049.severity = silent + +# Remove unnecessary 'Imports' or 'using' directive +dotnet_diagnostic.RemoveUnnecessaryImportsFixable.severity = warning + +# MSTest Analyzer Rules (MSTEST) + +# Explicitly enable or disable tests parallelization +dotnet_diagnostic.MSTEST0001.severity = warning + +# Test classes should have valid layout +dotnet_diagnostic.MSTEST0002.severity = warning + +# Test methods should have valid layout +dotnet_diagnostic.MSTEST0003.severity = warning + +# Public types should be test classes +dotnet_diagnostic.MSTEST0004.severity = none + +# Test context property should have valid layout +dotnet_diagnostic.MSTEST0005.severity = warning + +# Avoid [ExpectedException] +dotnet_diagnostic.MSTEST0006.severity = warning + +# Use test attributes only on test methods +dotnet_diagnostic.MSTEST0007.severity = warning + +# TestInitialize method should have valid layout +dotnet_diagnostic.MSTEST0008.severity = warning + +# TestCleanup method should have valid layout +dotnet_diagnostic.MSTEST0009.severity = warning + +# ClassInitialize method should have valid layout +dotnet_diagnostic.MSTEST0010.severity = warning + +# ClassCleanup method should have valid layout +dotnet_diagnostic.MSTEST0011.severity = warning + +# AssemblyInitialize method should have valid layout +dotnet_diagnostic.MSTEST0012.severity = warning + +# AssemblyCleanup method should have valid layout +dotnet_diagnostic.MSTEST0013.severity = warning + +# DataRow should be valid +dotnet_diagnostic.MSTEST0014.severity = warning + +# Test method should not be ignored +dotnet_diagnostic.MSTEST0015.severity = warning + +# Test class should have test method +dotnet_diagnostic.MSTEST0016.severity = warning + +# Assertion arguments should be passed in the correct order +dotnet_diagnostic.MSTEST0017.severity = warning + +# DynamicData should be valid +dotnet_diagnostic.MSTEST0018.severity = warning + +# Prefer TestInitialize methods over constructors +dotnet_diagnostic.MSTEST0019.severity = warning + +# Prefer constructors over TestInitialize methods +dotnet_diagnostic.MSTEST0020.severity = warning + +# Prefer Dispose over TestCleanup methods +dotnet_diagnostic.MSTEST0021.severity = warning + +# Prefer TestCleanup methods over Dispose +dotnet_diagnostic.MSTEST0022.severity = warning + +# Do not negate boolean assertions +dotnet_diagnostic.MSTEST0023.severity = warning + +# Do not store TestContext in a static member +dotnet_diagnostic.MSTEST0024.severity = warning + +# Use 'Assert.Fail' instead of an always-failing assert +dotnet_diagnostic.MSTEST0025.severity = warning + +# Avoid conditional access in assertions +dotnet_diagnostic.MSTEST0026.severity = warning + +# Non-nullable reference not initialized suppressor +dotnet_diagnostic.MSTEST0027.severity = none + +# Non-nullable reference not initialized suppressor +dotnet_diagnostic.MSTEST0028.severity = none + +# Public method should be test method +dotnet_diagnostic.MSTEST0029.severity = warning + +# Type containing [TestMethod] should be marked with [TestClass] +dotnet_diagnostic.MSTEST0030.severity = warning + +# System.ComponentModel.DescriptionAttribute has no effect on test methods +dotnet_diagnostic.MSTEST0031.severity = warning + +# Review or remove the assertion as its condition is known to be always true +dotnet_diagnostic.MSTEST0032.severity = warning + +# Non-nullable reference not initialized suppressor +dotnet_diagnostic.MSTEST0033.severity = none + +# Use ClassCleanupBehavior.EndOfClass with the [ClassCleanup] +dotnet_diagnostic.MSTEST0034.severity = warning + +# [DeploymentItem] can be specified only on test class or test method +dotnet_diagnostic.MSTEST0035.severity = warning + +# Do not use shadowing inside test class +dotnet_diagnostic.MSTEST0036.severity = warning + +# Use proper 'Assert' methods +dotnet_diagnostic.MSTEST0037.severity = warning + +# Don't use 'Assert.AreSame' or 'Assert.AreNotSame' with value types +dotnet_diagnostic.MSTEST0038.severity = warning + +# Use newer 'Assert.Throws' methods +dotnet_diagnostic.MSTEST0039.severity = warning + +# Do not assert inside 'async void' contexts +dotnet_diagnostic.MSTEST0040.severity = warning + +# Use 'ConditionBaseAttribute' on test classes +dotnet_diagnostic.MSTEST0041.severity = warning + +# Obsolete API Rules (SYSLIB) +# Rules for obsolete .NET APIs and platform-specific warnings + + +[*.{cs,vb}] + +# The UTF-7 encoding is insecure +dotnet_diagnostic.SYSLIB0001.severity = error + +# PrincipalPermissionAttribute is obsolete +dotnet_diagnostic.SYSLIB0002.severity = error + +# Code access security is not supported +dotnet_diagnostic.SYSLIB0003.severity = error + +# The constrained execution region (CER) feature is not supported +dotnet_diagnostic.SYSLIB0004.severity = error + +# The global assembly cache (GAC) is not supported +dotnet_diagnostic.SYSLIB0005.severity = error + +# Thread.Abort is not supported +dotnet_diagnostic.SYSLIB0006.severity = error + +# Default implementations of cryptography algorithms not supported +dotnet_diagnostic.SYSLIB0007.severity = error + +# CreatePdbGenerator is not supported +dotnet_diagnostic.SYSLIB0008.severity = error + +# The AuthenticationManager Authenticate and PreAuthenticate methods are not supported +dotnet_diagnostic.SYSLIB0009.severity = error + +# Unsupported remoting APIs +dotnet_diagnostic.SYSLIB0010.severity = error + +# BinaryFormatter serialization is obsolete +dotnet_diagnostic.SYSLIB0011.severity = error + +# Type or member is obsolete +dotnet_diagnostic.SYSLIB0012.severity = error + +# EscapeUriString is obsolete +dotnet_diagnostic.SYSLIB0013.severity = error + +# WebRequest, HttpWebRequest, ServicePoint, WebClient are obsolete +dotnet_diagnostic.SYSLIB0014.severity = error + +# DisablePrivateReflectionAttribute is obsolete +dotnet_diagnostic.SYSLIB0015.severity = error + +# GetContextInfo() is obsolete +dotnet_diagnostic.SYSLIB0016.severity = error + +# Strong-name signing is not supported and throws PlatformNotSupportedException +dotnet_diagnostic.SYSLIB0017.severity = error + +# Reflection-only loading is not supported and throws PlatformNotSupportedException +dotnet_diagnostic.SYSLIB0018.severity = error + +# Some RuntimeEnvironment APIs are obsolete +dotnet_diagnostic.SYSLIB0019.severity = error + +# IgnoreNullValues is obsolete +dotnet_diagnostic.SYSLIB0020.severity = error + +# Derived cryptographic types are obsolete +dotnet_diagnostic.SYSLIB0021.severity = error + +# The Rijndael and RijndaelManaged types are obsolete +dotnet_diagnostic.SYSLIB0022.severity = error + +# RNGCryptoServiceProvider is obsolete +dotnet_diagnostic.SYSLIB0023.severity = error + +# Creating and unloading AppDomains is not supported and throws an exception +dotnet_diagnostic.SYSLIB0024.severity = error + +# SuppressIldasmAttribute is obsolete +dotnet_diagnostic.SYSLIB0025.severity = error + +# X509Certificate and X509Certificate2 are immutable +dotnet_diagnostic.SYSLIB0026.severity = error + +# PublicKey.Key is obsolete +dotnet_diagnostic.SYSLIB0027.severity = error + +# X509Certificate2.PrivateKey is obsolete +dotnet_diagnostic.SYSLIB0028.severity = error + +# ProduceLegacyHmacValues is obsolete +dotnet_diagnostic.SYSLIB0029.severity = error + +# HMACSHA1 always uses the algorithm implementation provided by the platform +dotnet_diagnostic.SYSLIB0030.severity = error + +# EncodeOID is obsolete +dotnet_diagnostic.SYSLIB0031.severity = error + +# Recovery from corrupted process state exceptions is not supported +dotnet_diagnostic.SYSLIB0032.severity = error + +# Rfc2898DeriveBytes.CryptDeriveKey is obsolete +dotnet_diagnostic.SYSLIB0033.severity = error + +# CmsSigner(CspParameters) constructor is obsolete +dotnet_diagnostic.SYSLIB0034.severity = error + +# ComputeCounterSignature without specifying a CmsSigner is obsolete +dotnet_diagnostic.SYSLIB0035.severity = error + +# Regex.CompileToAssembly is obsolete +dotnet_diagnostic.SYSLIB0036.severity = error + +# AssemblyName members HashAlgorithm, ProcessorArchitecture, and VersionCompatibility are obsolete +dotnet_diagnostic.SYSLIB0037.severity = error + +# SerializationFormat.Binary is obsolete +dotnet_diagnostic.SYSLIB0038.severity = error + +# SslProtocols.Tls and SslProtocols.Tls11 are obsolete +dotnet_diagnostic.SYSLIB0039.severity = error + +# EncryptionPolicy.NoEncryption and EncryptionPolicy.AllowNoEncryption are obsolete +dotnet_diagnostic.SYSLIB0040.severity = error + +# Some Rfc2898DeriveBytes constructors are obsolete +dotnet_diagnostic.SYSLIB0041.severity = error + +# FromXmlString and ToXmlString on ECC types are obsolete +dotnet_diagnostic.SYSLIB0042.severity = error + +# ECDiffieHellmanPublicKey.ToByteArray is obsolete +dotnet_diagnostic.SYSLIB0043.severity = error + +# AssemblyName.CodeBase and AssemblyName.EscapedCodeBase are obsolete +dotnet_diagnostic.SYSLIB0044.severity = error + +# Some cryptographic factory methods are obsolete +dotnet_diagnostic.SYSLIB0045.severity = error + +# ControlledExecution.Run should not be used +dotnet_diagnostic.SYSLIB0046.severity = error + +# XmlSecureResolver is obsolete +dotnet_diagnostic.SYSLIB0047.severity = error + +# RSA.EncryptValue(Byte[]) and RSA.DecryptValue(Byte[]) are obsolete +dotnet_diagnostic.SYSLIB0048.severity = error + +# JsonSerializerOptions.AddContext is obsolete +dotnet_diagnostic.SYSLIB0049.severity = error + +# Formatter-based serialization is obsolete +dotnet_diagnostic.SYSLIB0050.severity = error + +# APIs that support obsolete formatter-based serialization are obsolete +dotnet_diagnostic.SYSLIB0051.severity = error + +# APIs that support obsolete mechanisms for Regex extensibility are obsolete +dotnet_diagnostic.SYSLIB0052.severity = error + +# AesGcm should indicate the required tag size for encryption and decryption +dotnet_diagnostic.SYSLIB0053.severity = error + +# Thread.VolatileRead and Thread.VolatileWrite are obsolete +dotnet_diagnostic.SYSLIB0054.severity = warning + +# AdvSimd.ShiftRightLogicalRoundedNarrowingSaturate* methods with signed parameters are obsolete +dotnet_diagnostic.SYSLIB0055.severity = warning + +# Assembly.LoadFrom that takes an AssemblyHashAlgorithm is obsolete +dotnet_diagnostic.SYSLIB0056.severity = warning + +# X509Certificate2 and X509Certificate constructors for binary and file content are obsolete +dotnet_diagnostic.SYSLIB0057.severity = warning + +# The KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherAlgorithmStrength, HashAlgorithm, and HashStrength properties of SslStream are obsolete +dotnet_diagnostic.SYSLIB0058.severity = warning + +# SystemEvents.EventsThreadShutdown callbacks aren't run before the process exits +dotnet_diagnostic.SYSLIB0059.severity = warning + +# Rfc2898DeriveBytes constructors are obsolete +dotnet_diagnostic.SYSLIB0060.severity = error + +# System.Linq.Queryable.MaxBy and System.Linq.Queryable.MinBy taking an IComparer are obsolete +dotnet_diagnostic.SYSLIB0061.severity = error + +# Source Generator Diagnostics (SYSLIB1xxx) + +# Logging method names can't start with an underscore +dotnet_diagnostic.SYSLIB1001.severity = error + +# Don't include log level parameters as templates in the logging message +dotnet_diagnostic.SYSLIB1002.severity = error + +# Logging method parameter names can't start with an underscore +dotnet_diagnostic.SYSLIB1003.severity = error + +# Could not find a required type definition +dotnet_diagnostic.SYSLIB1005.severity = error + +# Multiple logging methods cannot use the same event ID +dotnet_diagnostic.SYSLIB1006.severity = error + +# Logging methods must return void +dotnet_diagnostic.SYSLIB1007.severity = error + +# One of the arguments to a logging method must implement the ILogger interface +dotnet_diagnostic.SYSLIB1008.severity = error + +# Logging methods must be static +dotnet_diagnostic.SYSLIB1009.severity = error + +# Logging methods must be partial +dotnet_diagnostic.SYSLIB1010.severity = error + +# Logging methods cannot be generic +dotnet_diagnostic.SYSLIB1011.severity = error + +# Redundant qualifier in logging message +dotnet_diagnostic.SYSLIB1012.severity = error + +# Don't include exception parameters as templates in the logging message +dotnet_diagnostic.SYSLIB1013.severity = error + +# Logging template has no corresponding method argument +dotnet_diagnostic.SYSLIB1014.severity = error + +# Argument is not referenced from the logging message +dotnet_diagnostic.SYSLIB1015.severity = error + +# Logging methods cannot have a body +dotnet_diagnostic.SYSLIB1016.severity = error + +# A LogLevel value must be supplied in the LoggerMessage attribute or as a parameter to the logging method +dotnet_diagnostic.SYSLIB1017.severity = error + +# Don't include logger parameters as templates in the logging message +dotnet_diagnostic.SYSLIB1018.severity = error + +# Couldn't find a field of type ILogger +dotnet_diagnostic.SYSLIB1019.severity = error + +# Found multiple fields of type ILogger +dotnet_diagnostic.SYSLIB1020.severity = error + +# Multiple message-template item names differ only by case +dotnet_diagnostic.SYSLIB1021.severity = error + +# Can't have malformed format strings +dotnet_diagnostic.SYSLIB1022.severity = error + +# Generating more than six arguments is not supported +dotnet_diagnostic.SYSLIB1023.severity = error + +# System.Text.Json source generator did not generate output for type +dotnet_diagnostic.SYSLIB1030.severity = error + +# System.Text.Json source generator encountered a duplicate type info property name +dotnet_diagnostic.SYSLIB1031.severity = error + +# Context classes to be augmented by the System.Text.Json source generator must be declared as partial +dotnet_diagnostic.SYSLIB1032.severity = error + +# System.Text.Json source generator encountered a type with multiple [JsonConstructor] annotations +dotnet_diagnostic.SYSLIB1033.severity = warning + +# JsonSourceGenerator encountered a [JsonStringEnumConverter] annotation +dotnet_diagnostic.SYSLIB1034.severity = warning + +# System.Text.Json source generator encountered a type with multiple [JsonExtensionData] annotations +dotnet_diagnostic.SYSLIB1035.severity = error + +# System.Text.Json source generator encountered an invalid [JsonExtensionData] annotation +dotnet_diagnostic.SYSLIB1036.severity = error + +# System.Text.Json source generator encountered a type with init-only properties which are not supported for deserialization +dotnet_diagnostic.SYSLIB1037.severity = error + +# System.Text.Json source generator encountered a property annotated with [JsonInclude] but with inaccessible accessors +dotnet_diagnostic.SYSLIB1038.severity = error + +# Invalid GeneratedRegexAttribute usage +dotnet_diagnostic.SYSLIB1040.severity = error + +# Multiple GeneratedRegexAttribute attributes were applied to the same method, but only one is allowed +dotnet_diagnostic.SYSLIB1041.severity = error + +# The specified regular expression is invalid +dotnet_diagnostic.SYSLIB1042.severity = error + +# A GeneratedRegexAttribute method must be partial, parameterless, non-generic, and non-abstract, and return Regex +dotnet_diagnostic.SYSLIB1043.severity = error + +# The regex generator couldn't generate a complete source implementation for the specified regular expression due to an internal limitation +dotnet_diagnostic.SYSLIB1044.severity = error + +# Use GeneratedRegexAttribute to generate the regular expression implementation at compile time +dotnet_diagnostic.SYSLIB1045.severity = warning + +# Invalid LibraryImportAttribute usage +dotnet_diagnostic.SYSLIB1050.severity = error + +# The specified type is not supported by source-generated p/invokes +dotnet_diagnostic.SYSLIB1051.severity = error + +# The specified configuration is not supported by source-generated p/invokes +dotnet_diagnostic.SYSLIB1052.severity = error + +# The specified LibraryImportAttribute arguments cannot be forwarded to DllImportAttribute +dotnet_diagnostic.SYSLIB1053.severity = error + +# Use LibraryImportAttribute instead of DllImportAttribute to generate p/invoke marshalling code at compile time +dotnet_diagnostic.SYSLIB1054.severity = error + +# Invalid CustomMarshallerAttribute usage +dotnet_diagnostic.SYSLIB1055.severity = error + +# The specified native type is invalid +dotnet_diagnostic.SYSLIB1056.severity = error + +# The marshaller type does not have the required shape +dotnet_diagnostic.SYSLIB1057.severity = error + +# Invalid NativeMarshallingAttribute usage +dotnet_diagnostic.SYSLIB1058.severity = error + +# The marshaller type does not support an allocating constructor +dotnet_diagnostic.SYSLIB1059.severity = error + +# The specified marshaller type is invalid +dotnet_diagnostic.SYSLIB1060.severity = error + +# The marshaller type has incompatible method signatures +dotnet_diagnostic.SYSLIB1061.severity = error + +# The project must be updated with true +dotnet_diagnostic.SYSLIB1062.severity = error + +# Invalid JSImportAttribute usage +dotnet_diagnostic.SYSLIB1070.severity = error + +# Invalid JSExportAttribute usage +dotnet_diagnostic.SYSLIB1071.severity = error + +# The specified type is not supported by source-generated JavaScript interop +dotnet_diagnostic.SYSLIB1072.severity = error + +# The specified configuration is not supported by source-generated JavaScript interop +dotnet_diagnostic.SYSLIB1073.severity = error + +# JSImportAttribute requires unsafe code +dotnet_diagnostic.SYSLIB1074.severity = error + +# JSExportAttribute requires unsafe code +dotnet_diagnostic.SYSLIB1075.severity = error + +# Invalid GeneratedComInterfaceAttribute usage +dotnet_diagnostic.SYSLIB1090.severity = error + +# Method is declared in different partial declaration than the GeneratedComInterface attribute +dotnet_diagnostic.SYSLIB1091.severity = error + +# Usage of LibraryImport or GeneratedComInterface attribute does not follow recommendation +dotnet_diagnostic.SYSLIB1092.severity = error + +# Analysis for COM interface generation has failed +dotnet_diagnostic.SYSLIB1093.severity = error + +# The base COM interface failed to generate source. Code will not be generated for this interface +dotnet_diagnostic.SYSLIB1094.severity = error + +# Invalid GeneratedComClassAttribute usage +dotnet_diagnostic.SYSLIB1095.severity = error + +# Use GeneratedComInterfaceAttribute instead of ComImportAttribute to generate COM marshalling code at compile time +dotnet_diagnostic.SYSLIB1096.severity = error + +# This type implements at least one type with the GeneratedComInterfaceAttribute attribute +dotnet_diagnostic.SYSLIB1097.severity = error + +# .NET COM hosting with EnableComHosting only supports built-in COM interop +dotnet_diagnostic.SYSLIB1098.severity = error + +# COM Interop APIs on System.Runtime.InteropServices.Marshal do not support source-generated COM and will fail at run time +dotnet_diagnostic.SYSLIB1099.severity = error + +# Type is not supported +dotnet_diagnostic.SYSLIB1100.severity = error + +# Property on type is not supported +dotnet_diagnostic.SYSLIB1101.severity = error + +# Project's language version must be at least C# 11 +dotnet_diagnostic.SYSLIB1102.severity = error + +# Value types are invalid inputs to configuration 'Bind' methods +dotnet_diagnostic.SYSLIB1103.severity = error + +# Generator cannot determine the target configuration type +dotnet_diagnostic.SYSLIB1104.severity = error + +# Can't use ValidateObjectMembersAttribute or ValidateEnumeratedItemsAttribute on fields or properties with open generic types +dotnet_diagnostic.SYSLIB1201.severity = error + +# A member type has no fields or properties to validate +dotnet_diagnostic.SYSLIB1202.severity = error + +# A type has no fields or properties to validate +dotnet_diagnostic.SYSLIB1203.severity = error + +# Deriving from a GeneratedComInterface-attributed interface defined in another assembly is not supported +dotnet_diagnostic.SYSLIB1230.severity = error + +# Other Platform Diagnostics + +# SVE is a preview feature can be used by enabling EnablePreviewFeatures flag +dotnet_diagnostic.SYSLIB5003.severity = error + +# CS9035 - should be warning or error +dotnet_diagnostic.CS9035.severity = none + +# For Verify package based content +[*.{received,verified}.{cs,txt}] +charset = "utf-8-bom" +end_of_line = lf +indent_size = unset +indent_style = unset +insert_final_newline = false +tab_width = unset +trim_trailing_whitespace = false + +# The following sections are about +# ignoring certain rules for specific files or folders, such as settings, models, DTOs, etc. +# including when using the Extensions/ folder for matching non-local namespace extensions such as +# - Extensions/System/Linq/EnumerableExtensions.cs +# - Extensions/Microsoft/Extensions/DepdendencyInjection/ServiceCollectionExtensions.cs + +# If it's a settings, model, dto, etc file, ignore the 'properties' cannot have arrays +# and other annoying rules - the following section is duplicated +[**/*{ValueObjects,Settings,Options,Model,Models,DTO,Entity,Response,Request}{.Nav,}.{cs,vb}] +dotnet_diagnostic.CA1002.severity = none +dotnet_diagnostic.CA1024.severity = none +dotnet_diagnostic.CA1056.severity = none +dotnet_diagnostic.CA1819.severity = none +dotnet_diagnostic.CA2227.severity = none +dotnet_diagnostic.CS8618.severity = none +dotnet_diagnostic.CS8767.severity = none + +[**/{ValueObjects,Migrations,Entities,Models,DTOs,Settings,Options,Configuration,Requests,Responses}/**.{cs,vb}] +dotnet_diagnostic.CA1002.severity = none +dotnet_diagnostic.CA1024.severity = none +dotnet_diagnostic.CA1056.severity = none +dotnet_diagnostic.CA1819.severity = none +dotnet_diagnostic.CA2227.severity = none +dotnet_diagnostic.CS8618.severity = none +dotnet_diagnostic.CS8767.severity = none + +[**/Migrations/**.{cs,vb}] +# EF Core migrations (and their .Designer.cs snapshots) are generated; any diagnostics are worthless. +generated_code = true +dotnet_diagnostic.CA1062.severity = none +dotnet_diagnostic.CA1825.severity = none +dotnet_diagnostic.CA1861.severity = none +dotnet_diagnostic.IDE0053.severity = none +dotnet_diagnostic.IDE0300.severity = none +dotnet_diagnostic.RCS1021.severity = none +dotnet_diagnostic.RCS1205.severity = none +# PDS0004: Use correct acronym capitalization +dotnet_diagnostic.PDS0004.severity = none + +[**/Extensions/**.{cs,vb}] +# Files under the Extensions/ namespace-reset convention must not force developers to add pragmas; +# suppress only the namespace-conflict diagnostics that arise from declaring framework-style +# namespaces. Accessibility/design rules (for example CA1034) are deliberately not suppressed here. +dotnet_diagnostic.IDE0130.severity = none +dotnet_diagnostic.CA1724.severity = none +dotnet_diagnostic.CS0436.severity = none +dotnet_diagnostic.CS1591.severity = none +dotnet_diagnostic.IDE0005.severity = none + +[**/Generated/**/*.{cs,vb}] +generated_code = true +dotnet_diagnostic.CS8602.severity = none + +[**/*.Designer.cs] +# Designer/design-time generated content +generated_code = true + +[**/obj/**/*.cs] +[**/bin/**/*.cs] +# Compiler/build output (generated AssemblyInfo, GlobalUsings, etc.) +generated_code = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8d48b3e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,108 @@ +# Default: auto-detect text and normalise to LF in the index. +# Everything below only overrides the *working tree* checkout behaviour. +* text=auto + +# --------------------------------------------------------------------------- +# .NET source and project files +# No eol= means each dev's core.autocrlf / core.eol decides. Set eol=lf here +# instead if you want to hard-enforce LF everywhere -- but keep it in sync +# with end_of_line in .editorconfig or `dotnet format --verify-no-changes` +# will fail in CI. +# --------------------------------------------------------------------------- +*.cs text diff=csharp +*.csx text diff=csharp +*.vb text +*.fs text +*.fsx text +*.csproj text +*.fsproj text +*.vbproj text +*.props text +*.targets text +*.resx text +*.config text +*.ruleset text +*.xaml text +*.cshtml text diff=html +*.razor text diff=html +*.sln text eol=lf +*.slnx text eol=lf + +# --------------------------------------------------------------------------- +# Data, config and docs +# --------------------------------------------------------------------------- +*.json text +*.xml text +*.yml text +*.yaml text +*.toml text +*.md text diff=markdown +*.svg text +*.editorconfig text +.gitattributes text +.gitignore text + +# --------------------------------------------------------------------------- +# Web assets (only relevant if the repo ships any) +# --------------------------------------------------------------------------- +*.js text +*.jsx text +*.ts text +*.tsx text +*.html text diff=html +*.css text +*.scss text + +# --------------------------------------------------------------------------- +# Scripts -- these MUST be pinned regardless of platform. +# cmd.exe misparses LF in multi-line blocks and labels; sh needs LF. +# --------------------------------------------------------------------------- +*.sh text eol=lf +*.bash text eol=lf +*.zsh text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=lf +*.psm1 text eol=lf +*.psd1 text eol=lf +Dockerfile text eol=lf +*.dockerfile text eol=lf +justfile text eol=lf +Makefile text eol=lf + +# Patches carry their own endings -- never touch them. +*.patch -text + +# --------------------------------------------------------------------------- +# Binary +# --------------------------------------------------------------------------- +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.mp4 binary +*.zip binary +*.gz binary +*.7z binary +*.tar binary +*.exe binary +*.dll binary +*.pdb binary +*.obj binary +*.vsix binary +*.nupkg binary +*.snupkg binary +*.snk binary +*.pfx binary +*.lockb binary +*.woff binary +*.woff2 binary + +# --------------------------------------------------------------------------- +# Keep repo scaffolding out of `git archive` / source packages +# --------------------------------------------------------------------------- +.gitattributes export-ignore +.gitignore export-ignore +.github/ export-ignore diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6b9928e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,18 @@ +# GitHub Copilot Instructions + +## Primary instruction source + +Use the repository root [`AGENTS.md`](../AGENTS.md) as the **primary** source of truth for behavior, architecture context, testing standards, and completion criteria. + +If this file and `AGENTS.md` appear to conflict, prefer `AGENTS.md` unless this file explicitly states a GitHub Copilot-only exception. + +## Copilot-specific guidance + +This file should only contain **GitHub Copilot-specific** instruction details. +Keep product, architecture, and general engineering standards centralized in `AGENTS.md`. + +## Operating expectations for Copilot + +- Apply the `AGENTS.md` testing bar strictly (TUnit and TUnit.Mocks, `{SubjectUnderTest}_{Scenario}_{Expectation}` naming, cancellation token rule, an integration test for any change to the props or targets). +- Treat work as incomplete until relevant tests pass. +- Keep edits minimal, focused, and aligned with existing SDK and repository conventions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..b35e8e5 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,48 @@ +name: PR + +on: + pull_request: + branches: [main] + # `ready_for_review` is not one of the default activity types, so a draft PR would never + # re-evaluate the required build gate once it is marked ready for review. + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and test + uses: purview-dev/build/.github/workflows/purview-build.yml@main + with: + # The shared workflow defaults to the 10.0.x SDK, but this repository pins the .NET 11 SDK in + # global.json (the tests run on net10.0 and net11.0). Without the matching SDK the runner cannot + # resolve global.json and every step fails. + # Keep in sync with `sdk.version` in global.json. + dotnet-version: "11.0.100-rc.3.26522.148" + # Mirrors Build:TestFilter in purview-build.json: the unit tests and the integration tests, which + # build and run throwaway consumers against the real OpenAPI XML-comment generator. + test-filter: "/*/*/*/*" + run-pack: true + validate-pack: true + secrets: inherit + + # Every other job builds against the source props, targets and task assembly; this one builds what ships. It packs + # the package to an isolated feed and package cache, then builds and runs scripts/packed-consumer against it through + # PackageReference alone. `just smoke-packed-consumer` runs the same locally. + packed-consumer: + name: Packed consumer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # The SDK from global.json, plus the .NET 10 runtime the consumer runs on. + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + dotnet-version: 10.0.x + + - name: Pack, then build and run a consumer of the package + shell: pwsh + run: ./scripts/test-packed-consumer.ps1 -PackOutput artifacts/packed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..50e644f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + # Neither a reusable workflow nor a composite action can grant a scope, so the caller owns these: + # `packages: write` for the GitHub Packages mirror, `id-token: write` for the nuget.org Trusted + # Publishing exchange, which GitHub withholds by default. + packages: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + name: Release packages + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # A composite action, not a reusable workflow: nuget.org validates the OIDC `job_workflow_ref` + # claim against the registered Trusted Publishing policy, and inside a called workflow that + # claim names the called repository. Running it here keeps the claim on this file, which is the + # file name the policy is registered against. + - uses: purview-dev/build/actions/purview-release@main + with: + release-mode: NuGet + # The shared action defaults to the 10.0.x SDK, but this repository pins + # the .NET 11 SDK in global.json; without it the release job cannot resolve global.json. + # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml). + dotnet-version: "11.0.100-rc.3.26522.148" + trusted-publishing: true + # The nuget.org profile name, not the sign-in email. + nuget-user: kieronlanning + # A composite action has no `secrets` context, so the read token is passed explicitly. + packages-read-token: ${{ secrets.PURVIEW_PACKAGES_READ_TOKEN }} + # A composite action has no `vars` context, so repository variables are passed here. + source-mapping: ${{ vars.PURVIEW_NUGET_SOURCE_MAPPING }} + github-packages: ${{ vars.PURVIEW_PUBLISH_GITHUB_PACKAGES == 'true' }} + github-packages-keep-versions: ${{ vars.PURVIEW_GITHUB_PACKAGES_KEEP_VERSIONS || '1' }} + github-packages-retention-hours: ${{ vars.PURVIEW_GITHUB_PACKAGES_RETENTION_HOURS || '24' }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..728e6f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,667 @@ +# Created by https://www.toptal.com/developers/gitignore/api/openframeworks+visualstudio,visualstudiocode,jetbrains,react,node,astro,aspnetcore,ncrunch,go +# Edit at https://www.toptal.com/developers/gitignore?templates=openframeworks+visualstudio,visualstudiocode,jetbrains,react,node,astro,aspnetcore,ncrunch,go + +### ASPNETCore ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/ + +### Astro ### +# Gitignore template for Astro projects +# Ignore content collection generated files +.astro + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### JetBrains ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### JetBrains Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### NCrunch ### +# NCrunch +*.ncrunch*.user +*.crunch.xml + +### Node ### +# Logs +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### OpenFrameworks+VisualStudio ### +# ignore generated binaries +# but not the data folder + +/bin/* +!/bin/data/ + +# general + +[Bb]uild/ +*.o +[Dd]ebug*/ +[Rr]elease*/ +*.mode* +*.app/ +.svn/ + +# IDE files which should +# be ignored + +# XCode +*.pbxuser +*.perspective +*.perspectivev3 +*.mode1v3 +*.mode2v3 +# XCode 4 +xcuserdata +*.xcworkspace + +# Code::Blocks +*.depend +*.layout + +# Visual Studio + +# Eclipse +.metadata +local.properties +.externalToolBuilders + +# operating system + +# Linux +# KDE +.directory +.AppleDouble + +# OSX +.DS_Store +*.swp +*~.nib +# Thumbnails +._* + +# Windows +# Image file caches +Thumbs.db +# Folder config file +Desktop.ini + +# Android +.csettings + +### OpenFrameworks+VisualStudio Patch ### + +### react ### +.DS_* +**/*.backup.* +**/*.back.* + +node_modules + +*.sublime* + +psd +thumb +sketch + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/openframeworks+visualstudio,visualstudiocode,jetbrains,react,node,astro,aspnetcore,ncrunch,go + +!scripts/* + +.tools/purview-build/ + +# Test run reports produced by the Microsoft.Testing.Platform runner +TestResults/ + +# Agent skills are installed into the universal .agents/skills/ folder only, and that folder and +# skills-lock.json are committed. Per-agent folders (and Claude Code's local settings) stay out. +/.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..abb5c30 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,189 @@ +# Agent Instructions + +## Purpose and authority + +This repository contains `Purview.OpenAPI.XmlComments`: an MSBuild package that keeps the `Microsoft.AspNetCore.OpenApi` +XML-comment source generator from throwing `ArgumentException: An item with the same key has already been added` when +the same documentation ID comes from more than one assembly (dotnet/aspnetcore#67192). + +- This file is the repository-wide source of truth for AI agents. A more-specific `AGENTS.md` in a subtree, if one is + ever added, takes precedence for that subtree. +- `.github/copilot-instructions.md` defers to this file. +- Follow explicit user instructions first, then the nearest applicable repository instructions, then established code + patterns. +- Operate only in this repository unless the user explicitly expands the scope. +- Never read, copy, log, or commit secrets from excluded files, environment variables, user profiles, local + configuration, test output, or provider credentials. + +## Source of truth and layout + +| Path | Purpose | +| --- | --- | +| `src/XmlComments.slnx` | Canonical solution for restore, build, test and pack | +| `src/src/XmlComments` | The `netstandard2.0` task assembly (`DeduplicateOpenAPIXmlComments`, `CheckOpenAPIXmlCommentOverlap`, the deduplication rules) and the package | +| `src/src/XmlComments/Sdk` | Package-only assets: `buildTransitive/Purview.OpenAPI.XmlComments.props`/`.targets` and `README.md`, the package README | +| `src/tests/XmlComments.UnitTests` | TUnit tests of the rules and of both tasks, run in-process against a mocked `IBuildEngine` | +| `src/tests/XmlComments.IntegrationTests` | TUnit tests that build and run throwaway consumers against the real OpenAPI generator, and run the skill's diagnostic script against them | +| `src/tests/Repository.UnitTests` | `Purview.Skills.Testing` conformance for every authored skill, plus the product-prefix rule | +| `skills/purview-openapi-xmlcomments-duplicates` | The authored agent skill: `SKILL.md`, `references/`, and `scripts/find-duplicate-documentation-ids.ps1` | +| `scripts/packed-consumer`, `scripts/test-packed-consumer.ps1` | The packed-consumer gate | +| `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website | +| `Directory.Packages.props` | Centrally managed NuGet versions, and the OpenAPI versions the integration consumers use | +| `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour | +| `global.json` | Required .NET SDK, `Purview.BuildSdk` and Microsoft.Testing.Platform selection | +| `package.json` | Authoritative repository and package version | +| `.config/purview-build.json` | Shared `Purview.Build` pipeline configuration (solution, test discovery, test filter, pack validation) | +| `Justfile` | Supported local workflow commands | + +## Standard workflow + +1. Read this file, inspect the working tree, and locate the implementation, tests, documentation and existing patterns + relevant to the task. +2. Confirm behaviour from code and tests rather than relying on memory or documentation alone. +3. Make the smallest coherent change. Preserve public behaviour unless the task explicitly changes it. +4. Update tests for fixes and behaviour changes. Update documentation when public behaviour changes. +5. Run the narrowest meaningful validation first, then broader validation in proportion to risk. +6. Review the diff for unrelated edits, generated noise, compatibility risks, and missing docs or tests. + +## Behavioural invariants + +The public surface is MSBuild, not C#: the `PurviewOpenAPIXmlCommentDeduplication` property, the +`PurviewOpenAPIXmlCommentExclude` item, the `POAX001` warning and the `PurviewDeduplicateOpenAPIXmlComments` / +`PurviewCheckOpenAPIXmlCommentOverlap` target names. Renaming any of them is a breaking change. + +- **Remove every occurrence of a duplicated ID, never all but the first.** The generator also adds the API's own + public documented symbols, and a type injected into every assembly is usually declared by the API too, so a kept + reference copy would still collide with it. A duplicated ID is ambient code, not API surface. +- **Judge duplicates on the generator's key.** `DocumentationId.Normalize` mirrors `XmlCommentGenerator.NormalizeDocId` + (a method's `~ReturnType` suffix is stripped except on `op_Implicit`/`op_Explicit`). Keep it in step with the + generator. +- **Only XML documentation files are touched.** A `` root holding ``; any other `AdditionalFiles` XML, and + any file that cannot be read or parsed, is passed through untouched rather than failing the build. +- **Never defeat incremental compilation.** A filtered copy is a compiler input, so it is written only when its content + changes. `Build_GivenUnchangedInputs_DoesNotRecompileTheAPI` proves it. +- **Resolve paths inside targets.** `IntermediateOutputPath` is empty while package props and targets are evaluated, + and a task resolves a relative path against the MSBuild process's working directory, so output paths are built in + the target with `$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', ...))`. +- **Hang everything off the OpenAPI package's own target.** `PurviewDeduplicateOpenAPIXmlComments` runs + `AfterTargets="GenerateAdditionalXmlFilesForOpenApi"`, so a project without `Microsoft.AspNetCore.OpenApi` is left + untouched. Do not re-derive the project-reference XML list: take the `.xml` items in `AdditionalFiles`, so the package + follows whatever the OpenAPI package adds. +- **`POAX001` is the only diagnostic, and it is a warning.** The project's own contribution is only known after + `CoreCompile`, so the overlap check cannot fix the current build; it names the `PurviewOpenAPIXmlCommentExclude` that + fixes the next one. It can over-report (the generator only reads the project's public symbols), which is why it is + never an error by default. + +## Task assembly rules + +- `netstandard2.0` only, so it loads in `dotnet build` and in Visual Studio's .NET Framework MSBuild. Do not use APIs + that target does not have (no `NotNullWhen`, no `IsExternalInit`-backed records or `init`). +- `Microsoft.Build.Framework` and `Microsoft.Build.Utilities.Core` are compile-time only (`PrivateAssets="all"`, + `ExcludeAssets="runtime"`). A packed copy beside the task breaks type identity for `ITask`/`ITaskItem`, so + `**/Microsoft.Build*` is forbidden content. +- Task classes derive from `Microsoft.Build.Utilities.Task` written out in full: the SDK's implicit + `System.Threading.Tasks` using makes a bare `Task` ambiguous. +- Keep the rules in `XmlCommentDeduplicator` and `XmlDocumentation` and the tasks thin, so the rules are unit-tested + without MSBuild. +- Task item parameters are `ITaskItem[]` because MSBuild binds nothing else; that is the justified `CA1819` suppression. + +## Packaging rules + +- A project is packable **only** when its own `.csproj` declares `true`. The SDK reads that + declaration from the project file text, so never move it into `Directory.Build.props`. +- The assembly ships under `tasks/netstandard2.0/` (`BuildOutputTargetFolder=tasks`), never `lib/`, `ref/` or + `runtimes/`, which are forbidden content. `DevelopmentDependency=true` and `SuppressDependenciesWhenPacking=true` + keep it off consumers' output and out of their packages. +- `Sdk/**` is packed to the package root, so `Sdk/buildTransitive/*` becomes `buildTransitive/*` and `Sdk/README.md` + becomes the package README. The props resolve the task assembly relative to themselves + (`../tasks/netstandard2.0/Purview.OpenAPI.XmlComments.dll`); only the packed-consumer gate proves that path. +- `.config/purview-build.json`'s `PackValidation.RequiredContent` is the exhaustive declaration of what the package + ships. Update it whenever package content changes, and verify with `just pipeline-pack-validate`. + +## Testing conventions + +- TUnit on Microsoft.Testing.Platform only - never xUnit, NUnit, MSTest, NSubstitute or FluentAssertions. Mock with + `TUnit.Mocks`; `TUnit.Mocks` and `Bogus` are added by `Purview.BuildSdk`, so do not add them as `PackageReference`s. +- Keep the `*.UnitTests` / `*.IntegrationTests` naming: the SDK derives the test category from it. +- Use `--treenode-filter`, not `dotnet test --filter`. +- Test naming is `{SubjectUnderTest}_{Scenario}_{Expectation}`, one `{Class}Tests` class per class under test in the + namespace of the class under test, explicit `// Arrange` / `// Act` / `// Assert`, and a trailing `CancellationToken` + whenever anything in the call chain accepts one. Documentation content comes from `TestBuilder`. +- An integration consumer is written to its own temporary directory with its own `Directory.Build.props`, + `Directory.Build.targets` and `Directory.Packages.props`, loads a private copy of the task assembly (a lingering + MSBuild node locks the one it loaded), and is built through `ProcessRunner`, which strips the host's `MSBUILD*` and + CI variables. Locate the task assembly through a type with no MSBuild base class: loading a task type needs MSBuild + assemblies the test process does not have. +- `Build_GivenDeduplicationDisabled_ReproducesTheDuplicateKeyFailure` is the proof that the harness can see the bug. + Keep it: without it, every other integration test could pass against a generator that no longer fails. +- Run `just smoke-packed-consumer` when the props, targets, packaging or a dependency version changes. +- `FindDuplicateDocumentationIdsScriptTests` runs the skill's script with `-Json` against built consumers and needs + `pwsh` on the path. Its JSON shape (`Findings[].Kind`/`Handled`/`Sources`, `Cache.DuplicateKeys`, `WouldThrow`, + `Recommendations`) and exit codes (`0`/`1`/`2`) are what the skill documents; change them together. + +## Agent skills + +Authored skills live in the repository-root `skills/` folder and are delivered by the skills CLI from git +(`npx skills add purview-dev/openapi-xmlcomments`). **Nothing packs them into the NuGet package**, and no `.agents/` +or `.claude/` copy is committed. + +| Skill | Answers | +| --- | --- | +| `skills/purview-openapi-xmlcomments-duplicates/` | the duplicate-key failure and `POAX001`: diagnosing every colliding ID and its sources, configuring the package, verifying the fix | + +- Every skill name carries the `purview-openapi-xmlcomments` prefix: skills install into one flat folder per agent, so + an unprefixed name collides. `Repository.UnitTests` enforces it. +- `[InheritsTests]` on `SkillTests` is load-bearing: TUnit does not walk base types, so without it nothing is checked. +- A link from a `SKILL.md` must stay inside that skill's folder; only that folder is installed. +- Keep `SKILL.md` under 500 lines and put task-shaped detail in `references/`. The skill is part of the MSBuild + surface's documentation: a renamed property, item, warning or target changes it in the same commit. +- The script mirrors the package's rules (`Get-NormalizedId` is `DocumentationId.Normalize`); keep them in step. + +## Documentation rules + +- Every public member carries XML documentation; document behaviour that is not obvious from a signature in + ``. +- Keep `src/src/XmlComments/Sdk/README.md`, the root `README.md`, `docs/wiki`, `AGENTS.md` and the code aligned. A + change to a property, item, warning, target name or default changes all of them in the same commit. +- `docs/wiki` is aggregated by the purview-dev website (landing page `Getting-Started.md`). Keep `_Sidebar.md`'s order + matching the pages that exist, and keep every page to a single top-level `#` heading. + +## Build, format, test and pack commands + +```text +./scripts/restore-dotnet-tools.ps1 +just restore +just build +just test +just test-unit +just test-integration +just lint-check +just pack +just smoke-packed-consumer +just find-duplicates +just pipeline-pr +just pipeline-pack-validate +``` + +Use `just lint-fix` (CSharpier format plus the `.slnx` GUID cleanup) to fix formatting. + +## Versioning and releases + +- `package.json` is the authoritative release and package version. Do not manually diverge project versions. +- Release is automatic on push to `main`: `.github/workflows/release.yml` calls the shared `purview-dev/build` release + action with `Release:Mode=NuGet`, which packs, publishes and creates the `v` GitHub release only when that tag + does not already exist. +- `.github/workflows/pr.yml` runs the shared build pipeline for pull requests and the packed-consumer gate. +- Never create release tags or publish packages manually unless the user explicitly requests a documented recovery + procedure. +- `.github/workflows/*` and `global.json` must stay in sync: the workflows' `dotnet-version` input has to match + `sdk.version`, and `.config/purview-build.json` has to keep pointing at `src/XmlComments.slnx`. + +## Completion checklist + +- Confirm the requested behaviour and scope are satisfied, and only intended files changed. +- Review the MSBuild surface, package content and dependency implications. +- Add or update focused unit tests, and an integration test for any change to the props or targets. +- Update `Sdk/README.md`, the root `README.md`, `docs/wiki`, the skill, `CHANGELOG.md` and `AGENTS.md` when the change + affects them. +- Run appropriate build, test, formatting, pack and packed-consumer checks in proportion to risk, and state exactly what + ran. If a check was skipped or blocked, give the concrete reason and the remaining risk. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..17f2ccb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this repository are recorded here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version; see +[Release Flow](docs/wiki/Release-Flow.md). + +Released versions correspond to `v` GitHub releases. + +## 1.0.0-prerelease.1 + +The first prerelease. + +### Added + +- `Purview.OpenAPI.XmlComments`, a development-dependency MSBuild package for projects that use the + `Microsoft.AspNetCore.OpenApi` XML-comment source generator (dotnet/aspnetcore#67192). + - Before compile, removes every occurrence of each documentation ID that occurs more than once across the XML + documentation files in `AdditionalFiles`, judged after the generator's own `~ReturnType` normalisation, and swaps + filtered copies in. A copy is only rewritten when its content changes, so incremental builds still skip compile. + - `PurviewOpenAPIXmlCommentExclude` items remove an ID even when it occurs once. + - After compile, `POAX001` warns about an ID documented both in the project and in a reference, naming the exclude + to add. + - `PurviewOpenAPIXmlCommentDeduplication=false` turns it off. +- The `purview-openapi-xmlcomments-duplicates` agent skill (`npx skills add purview-dev/openapi-xmlcomments`): recognising + the duplicate-key failure and `POAX001`, diagnosing every colliding ID and its sources, configuring the package, and + verifying the fix. It bundles `find-duplicate-documentation-ids.ps1` (also `just find-duplicates `), which + reports each collision, whether the current configuration handles it, and the configuration to add, with `-Json` + output and an exit code for CI. diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..c8a6fe4 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,42 @@ + + + + + 18.9.6 + + + + 10.0.14 + 11.0.0-rc.3.26522.148 + + + 1.76.0 + 1.2.0 + + + + + + + + + + + + + + + + + + + diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..829899c --- /dev/null +++ b/Justfile @@ -0,0 +1,149 @@ +set quiet + +export TESTINGPLATFORM_EXITCODE_IGNORE := "8" +export DOTNET_CLI_TELEMETRY_OPTOUT := "1" +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE := "1" +export DO_NOT_TRACK := "1" + +solution := "src/XmlComments.slnx" +build_configuration := "Debug" + +artifacts_folder := "./artifacts" +default_test_filter := "/*/*/*/*" + +pipeline_tool := "dotnet purview-build" + +current_version := `bun -p "require('./package.json').version"` + +[private] +default: + just --list + +# Restore the repository's .NET local tools (.config/dotnet-tools.json) +[private] +ensure-pipeline-tool: + pwsh -NoProfile -File scripts/restore-dotnet-tools.ps1 + +# Run the PR pipeline (restore, build, lint, tests) +[group('Pipeline')] +pipeline-pr *args: + just ensure-pipeline-tool + echo "Running PR pipeline..." + {{ pipeline_tool }} {{ args }} + +# Run the build pipeline (restore, build, lint) +[group('Pipeline')] +pipeline-build *args: + just ensure-pipeline-tool + echo "Running build pipeline..." + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} + +# Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) +[group('Pipeline')] +pipeline-release *args: + just ensure-pipeline-tool + just lint-fix + echo "Running release pipeline..." + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} + +# Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) +# Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. +# Use the LOCAL_NUGET_FEED_PATH environment variable or forward slashes, e.g. +# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +[group('Pipeline')] +pipeline-local-release *args: + just ensure-pipeline-tool + just lint-fix + echo "Running local release pipeline..." + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} + +# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing +[group('Pipeline')] +pipeline-pack-validate *args: + just ensure-pipeline-tool + echo "Running pack + validate pipeline..." + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + +# Displays the current version from package.json +[group('Build and Test')] +version: + echo "Current version is {{ BLUE }}{{ current_version }}{{ NORMAL }}" + +# Build with the specified configuration, defaulting to "Debug" +[group('Build and Test')] +build *args: + echo "Building {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet build {{ solution }} -c {{ build_configuration }} {{ args }} + +# Clean with the specified configuration, defaulting to "Debug" +[group('Build and Test')] +clean *args: + echo "Cleaning {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet clean {{ solution }} -c {{ build_configuration }} {{ args }} + +# Run tests with the specified configuration, defaulting to "Debug" +[group('Build and Test')] +test filter=default_test_filter *args: + echo "Running tests for {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }} and filter {{ GREEN }}{{ filter }}{{ NORMAL }}" + dotnet test {{ solution }} -c {{ build_configuration }} --treenode-filter "{{ filter }}" -- {{ args }} + +# Run the unit tests +[group('Build and Test')] +test-unit *args: + just test "/*/*/*/*[Category=Unit]" {{ args }} + +# Run the integration tests (builds and runs throwaway consumers against the real OpenAPI generator) +[group('Build and Test')] +test-integration *args: + just test "/*/*/*/*[Category=Integration]" {{ args }} + +# Restore dependencies for the solution +[group('Build and Test')] +restore *args: + echo "Restoring dependencies for {{ BLUE }}{{ solution }}{{ NORMAL }}" + dotnet restore {{ solution }} {{ args }} + +# Create NuGet package for the project +[group('Build and Test')] +pack publish_folder=artifacts_folder *args: + echo "Packing {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }} to {{ GREEN }}{{ publish_folder }}{{ NORMAL }}" + echo " Current version is {{ BLUE }}{{ current_version }}{{ NORMAL }}" + dotnet pack {{ solution }} -c {{ build_configuration }} -o {{ publish_folder }} {{ args }} + +# Prove the packed package works for a real consumer: packs it to an isolated feed and package cache, then builds and runs +# scripts/packed-consumer against it through PackageReference alone. See scripts/test-packed-consumer.ps1. +[group('Build and Test')] +smoke-packed-consumer *args: + echo "Running the packed consumer gate..." + pwsh -NoProfile -File scripts/test-packed-consumer.ps1 {{ args }} + +# Report the documentation IDs that would make an API's OpenAPI XML-comment cache throw, and what to configure +# (the purview-openapi-xmlcomments-duplicates skill's script), e.g. just find-duplicates ../my-repo/src/Api +[group('Utilities')] +find-duplicates project *args: + pwsh -NoProfile -File skills/purview-openapi-xmlcomments-duplicates/scripts/find-duplicate-documentation-ids.ps1 -Project {{ project }} {{ args }} + +# Open the solution in Visual Studio/ Registered application +[group('Utilities')] +vs: + open {{ solution }} + +# Fix code formatting issues using CSharpier (and remove GUID Id attributes from .slnx files first) +[group('Utilities')] +lint-fix *args: + bun run lint:slnx:fix + dotnet csharpier format . {{ args }} + +# Check code formatting using CSharpier (and validate .slnx files have no GUID Id attributes) +[group('Utilities')] +lint-check *args: + bun run lint:slnx:check + dotnet csharpier check . {{ args }} + +# Clean up the repository by removing build artifacts, bin/obj folders etc, and shutting down the build server +[group('Utilities')] +scrub: + find . -type d \( -name bin -o -name obj \) -exec rm -rf {} + + just clean + just restore --force-evaluate + dotnet build-server shutdown diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9ea2dfc --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2026 Kieron Lanning + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3184760 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Purview OpenAPI XML Comments + +[![NuGet version](https://img.shields.io/nuget/v/Purview.OpenAPI.XmlComments.svg)](https://www.nuget.org/packages/Purview.OpenAPI.XmlComments) +[![Release](https://github.com/purview-dev/openapi-xmlcomments/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/openapi-xmlcomments/actions/workflows/release.yml) + +An MSBuild package that stops the `Microsoft.AspNetCore.OpenApi` XML-comment source generator from failing with +`ArgumentException: An item with the same key has already been added` when the same documentation ID comes from more +than one assembly - without turning the generator off, so every XML description stays in your OpenAPI document. + +## Packages + +| Package | Purpose | Targets | +| --- | --- | --- | +| [`Purview.OpenAPI.XmlComments`](src/src/XmlComments/Sdk/README.md) | Removes colliding documentation IDs from the referenced XML documentation the generator reads, before compile, and warns (`POAX001`) about any it can only see after. A development dependency with no runtime footprint. | MSBuild (`netstandard2.0` task) | + +The package README is the same file that ships inside the `.nupkg` (`Sdk/README.md`), so the table links straight to +the package documentation. + +## Quick start + +```xml + + +``` + +That is the whole change: nothing to configure for the common case, where a source generator or polyfill injects the +same documented type into two or more referenced projects. Remove any +`` workaround. + +When only one referenced project carries the injected type, the build says so and names the line to add: + +```xml + + + +``` + +## Finding what collides + +The exception names only the first colliding ID. The diagnostic script lists every one, where each copy comes from, +whether your configuration already handles it, and what to add when it does not: + +```bash +pwsh skills/purview-openapi-xmlcomments-duplicates/scripts/find-duplicate-documentation-ids.ps1 -Project path/to/Api +``` + +See [Troubleshooting](docs/wiki/Troubleshooting.md). + +## Agent skill + +AI coding agents get the whole procedure - recognising the failure, running the diagnostic, updating the +configuration and verifying the fix - from the `purview-openapi-xmlcomments-duplicates` skill. It is published from +this repository rather than any package: + +```bash +npx skills add purview-dev/openapi-xmlcomments +``` + +See [Agent Skills](docs/wiki/Agent-Skills.md). + +## Why it exists + +The generator builds one dictionary with `Add` from every `` of every project-reference XML documentation file +and every public documented symbol of the API's own compilation, and de-duplicates neither +([dotnet/aspnetcore#67192](https://github.com/dotnet/aspnetcore/issues/67192)). A type injected into every assembly +therefore arrives several times and `/openapi/v1.json` answers `500`. See +[How It Works](docs/wiki/How-It-Works.md). + +## Repository + +| Path | Purpose | +| --- | --- | +| `src/src/XmlComments` | The task assembly and the package (`Sdk/buildTransitive`, `Sdk/README.md`) | +| `src/tests/XmlComments.UnitTests` | The deduplication rules and both tasks, in-process | +| `src/tests/XmlComments.IntegrationTests` | Real consumers built and run against the real OpenAPI generator, and diagnosed with the skill's script | +| `src/tests/Repository.UnitTests` | The authored skill against the Agent Skills specification | +| `skills/purview-openapi-xmlcomments-duplicates` | The agent skill, its references and its diagnostic script | +| `scripts/test-packed-consumer.ps1` | The packed package, consumed through `PackageReference` alone | +| `docs/wiki` | The documentation suite the purview-dev website aggregates | + +```text +just build +just test +just smoke-packed-consumer +just pipeline-pack-validate +just find-duplicates path/to/Api +``` + +See [Contributing](docs/wiki/Contributing.md) and [AGENTS.md](AGENTS.md). + +## License + +[MIT](LICENSE.md) diff --git a/assets/images/purview-logo-light.png b/assets/images/purview-logo-light.png new file mode 100644 index 0000000..70a98cf Binary files /dev/null and b/assets/images/purview-logo-light.png differ diff --git a/assets/images/purview-logo.png b/assets/images/purview-logo.png new file mode 100644 index 0000000..3eee1e9 Binary files /dev/null and b/assets/images/purview-logo.png differ diff --git a/commitlint.config.mts b/commitlint.config.mts new file mode 100644 index 0000000..1d3240b --- /dev/null +++ b/commitlint.config.mts @@ -0,0 +1,20 @@ +export default { + rules: { + 'body-leading-blank': [1, 'always'], + 'body-max-line-length': [2, 'always', 100], + 'footer-leading-blank': [1, 'always'], + 'footer-max-line-length': [2, 'always', 100], + 'header-max-length': [2, 'always', 100], + 'header-trim': [2, 'always'], + 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], + 'subject-empty': [2, 'never'], + 'subject-full-stop': [2, 'never', '.'], + 'type-case': [2, 'always', 'lower-case'], + 'type-empty': [2, 'never'], + 'type-enum': [ + 2, + 'always', + ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'], + ], + }, +}; diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md new file mode 100644 index 0000000..e9a83fa --- /dev/null +++ b/docs/wiki/Agent-Skills.md @@ -0,0 +1,38 @@ +# Agent Skills + +This repository authors an **agent skill** so AI coding agents can diagnose and fix duplicate OpenAPI XML-comment +members without rediscovering how the generator works. Skills are authored at `skills//SKILL.md` and installed +with the skills CLI, which clones this git repository - **no NuGet package ships them**. + +```bash +npx skills add purview-dev/openapi-xmlcomments +``` + +## Skills published by this repository + +| Skill | Covers | +| --- | --- | +| `skills/purview-openapi-xmlcomments-duplicates` | Recognising the `An item with the same key has already been added` failure and `POAX001`, finding every colliding documentation ID and its sources, installing and configuring `Purview.OpenAPI.XmlComments`, and verifying the fix | + +It bundles: + +- `scripts/find-duplicate-documentation-ids.ps1` - the diagnostic described in [Troubleshooting](Troubleshooting.md). + The integration tests run it against real consumers, so the skill's instructions stay true to its output. +- `references/diagnosing.md` - the same diagnosis by hand: the generated cache, the `AdditionalFiles` query, finding + the generator that injects the type, and binary logs. +- `references/configuration.md` - every configuration form: `PackageReference`, Central Package Management, + `GlobalPackageReference`, excludes for one or several APIs, warnings and turning it off. + +## When an agent should load it + +- `/openapi/v1.json` answers `500`, or a log shows `XmlCommentCache.GenerateCacheEntries` with a duplicate key. +- A build warns `POAX001`. +- A project removes the `Microsoft.AspNetCore.OpenApi.SourceGenerators` analyzer. + +## Authoring rules + +- Every skill name starts with `purview-openapi-xmlcomments`: skills install into one flat folder per agent, so an + unprefixed name collides with somebody else's. +- A skill links only inside its own folder, because only that folder is installed. +- `src/tests/Repository.UnitTests` validates every skill on every test run against the Agent Skills specification + through `Purview.Skills.Testing`. diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md new file mode 100644 index 0000000..a3edcf6 --- /dev/null +++ b/docs/wiki/Configuration.md @@ -0,0 +1,37 @@ +# Configuration + +Nothing needs configuring for the common case. Everything below is MSBuild, set in the API project or in +`Directory.Build.props`. + +| Setting | Default | Effect | +| --- | --- | --- | +| `false` | `true` | Turns the package off: neither target runs. | +| `` | none | Removes that ID from every referenced documentation file even when it occurs once. Normalised like every other ID, so a method ID may be written with or without its `~ReturnType` suffix. | + +## Warnings + +`POAX001` is an ordinary MSBuild warning (see [Diagnostics](Diagnostics.md)): + +```xml + + + $(MSBuildWarningsAsErrors);POAX001 + + $(MSBuildWarningsAsMessages);POAX001 + +``` + +## Seeing what it did + +Every removal is logged at normal verbosity: + +```text +dotnet build -v:n +... +OpenAPI XML comments: removed 'T:Ambient.InjectedAttribute' from Lib.A, Lib.B (duplicate). +``` + +The filtered copies are in `obj///purview-openapi-xml/`. With +`true`, the generator's cache is in +`obj/.../generated/Microsoft.AspNetCore.OpenApi.SourceGenerators/.../OpenApiXmlCommentSupport.generated.cs`, where each +ID should now appear once. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md new file mode 100644 index 0000000..70ddf26 --- /dev/null +++ b/docs/wiki/Contributing.md @@ -0,0 +1,21 @@ +# Contributing + +1. Read [AGENTS.md](https://github.com/purview-dev/openapi-xmlcomments/blob/main/AGENTS.md): it lists the behavioural + invariants and the packaging rules. +2. Restore the local tools: `./scripts/restore-dotnet-tools.ps1`. +3. Make the change with its tests. A change to the props or targets needs an integration test; a change to the skill's + script needs a `FindDuplicateDocumentationIdsScriptTests` case. +4. Run `just lint-fix`, `just test` and, for anything touching packaging, `just smoke-packed-consumer` and + `just pipeline-pack-validate`. +5. Use Conventional Commits (`feat:`, `fix:`, `docs:`, ...); `lefthook` and `commitlint` check them. + +When the MSBuild surface changes - a property, item, target name, warning or default - update the package README +(`src/src/XmlComments/Sdk/README.md`), the root `README.md`, these pages, the skill +(`skills/purview-openapi-xmlcomments-duplicates`, including its `references/` and script), `CHANGELOG.md` and +`AGENTS.md` in the same commit. + +## Skills + +The authored skill lives in `skills/`. Keep `SKILL.md` under 500 lines with detail in `references/`, link only inside +the skill's own folder, and keep the `purview-openapi-xmlcomments` prefix; `Repository.UnitTests` enforces the +specification. See [Agent Skills](Agent-Skills.md). diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md new file mode 100644 index 0000000..6ad59f4 --- /dev/null +++ b/docs/wiki/Diagnostics.md @@ -0,0 +1,27 @@ +# Diagnostics + +## POAX001 + +```text +warning POAX001: 'T:Ambient.InjectedAttribute' is documented both in this project and in Lib.A.xml. If this +project's copy is public, the OpenAPI XML-comment cache throws "An item with the same key has already been added" at +run time; add to drop the reference's copy. +``` + +**Cause.** The ID is documented in the API project itself and in exactly one referenced project. It is not a duplicate +among the referenced documentation, so it was not removed before compile, but the generator also adds the API's own +public documented symbols, so it will collide. + +**Fix.** Add the item the warning names to the API project: + +```xml + + + +``` + +The overlap is only visible after the API compiles, so the exclude takes effect on the next build. + +**False positives.** The generator only reads the API's *public* symbols, so an overlap with an `internal` type in the +API does not fail. The warning cannot tell the two apart from the documentation file; silence it with +`MSBuildWarningsAsMessages` if it is one of those (see [Configuration](Configuration.md)). diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md new file mode 100644 index 0000000..67e108d --- /dev/null +++ b/docs/wiki/Getting-Started.md @@ -0,0 +1,56 @@ +# Getting Started + +`Purview.OpenAPI.XmlComments` keeps the `Microsoft.AspNetCore.OpenApi` XML-comment source generator working when the +same documentation ID comes from more than one assembly. Without it, `/openapi/v1.json` answers `500` with: + +```text +System.ArgumentException: An item with the same key has already been added. Key: T:Some.Namespace.SomeType + at ...XmlCommentCache.GenerateCacheEntries() +``` + +## Do you need it? + +You do if all of these are true: + +- the API project references `Microsoft.AspNetCore.OpenApi` and its XML-comment generator is on (the default); +- the API references projects that generate XML documentation (`GenerateDocumentationFile`); +- a documented type with the same full name is compiled into more than one of them - typically an attribute or polyfill + a source generator injects into every assembly, such as `Purview.Results.GenerateResultAttribute`. + +The key in the exception names the type - but only the first collision. To list every colliding ID and where each +copy comes from, run the diagnostic in [Troubleshooting](Troubleshooting.md); AI agents get the same procedure from +the [`purview-openapi-xmlcomments-duplicates`](Agent-Skills.md) skill. + +## Install + +```xml + + +``` + +or once for every project in the repository (it does nothing where there is no OpenAPI package): + +```xml + + +``` + +Then remove any workaround that turns the generator off, such as + +```xml + +``` + +## Build + +Build as usual. If the injected type is carried by only **one** referenced project, the build warns +[`POAX001`](Diagnostics.md) and names the line to add: + +```xml + + + +``` + +Next: [How It Works](How-It-Works.md), [Configuration](Configuration.md) and, if anything still fails, +[Troubleshooting](Troubleshooting.md). diff --git a/docs/wiki/How-It-Works.md b/docs/wiki/How-It-Works.md new file mode 100644 index 0000000..4b2d5dc --- /dev/null +++ b/docs/wiki/How-It-Works.md @@ -0,0 +1,52 @@ +# How It Works + +## The generator's cache + +`Microsoft.AspNetCore.OpenApi` ships a source generator that turns XML documentation into OpenAPI descriptions. It +emits a cache, built on first use, with one `Dictionary.Add` per documentation ID, from two +sources: + +1. **Project-reference documentation.** The package's `GenerateAdditionalXmlFilesForOpenApi` target adds the XML + documentation file of each project reference to `AdditionalFiles`, and the generator adds every `` in each + one. There is no accessibility filter: `internal` members are in those files too. +2. **The API's own compilation.** Every public, documented symbol - including the output of other source generators, + whose post-initialization sources are visible to it. + +Neither is de-duplicated ([dotnet/aspnetcore#67192](https://github.com/dotnet/aspnetcore/issues/67192)). A type injected +into every assembly is added once per referenced project and once more from the API, and the second `Add` throws when +the first schema is transformed. + +## What the package changes + +```text +ResolveReferences + GenerateAdditionalXmlFilesForOpenApi (Microsoft.AspNetCore.OpenApi: adds project-reference XML) + PurviewDeduplicateOpenAPIXmlComments (this package: filters it) +CoreCompile (the generator runs here, on the filtered copies) + PurviewCheckOpenAPIXmlCommentOverlap (this package: POAX001) +``` + +**Before compile**, `PurviewDeduplicateOpenAPIXmlComments`: + +1. reads every `.xml` item in `AdditionalFiles` that is an XML documentation file (a `` root holding ``); + anything else, or anything unreadable, is passed through untouched; +2. keys each `` the way the generator does, stripping a method's `~ReturnType` suffix except on conversion + operators; +3. selects every ID that occurs more than once, plus every `PurviewOpenAPIXmlCommentExclude` ID; +4. removes **every** occurrence of each, writes a filtered copy of each file it changed to + `obj///purview-openapi-xml/`, and swaps the copies into `AdditionalFiles`. + +Every occurrence goes, not all but the first, because the API normally declares the injected type as well: its own +copy is then the only one left, and keeping a reference's copy would still collide with it. A duplicated ID is ambient +code, not API surface, so no endpoint or schema description is lost. A copy is only rewritten when its content +changes, so a no-change build still skips `CoreCompile`. + +**After compile**, `PurviewCheckOpenAPIXmlCommentOverlap` compares the API's fresh documentation file with the filtered +reference documentation and reports [`POAX001`](Diagnostics.md) for any ID still in both - the case where only one +referenced project carries the type, so step 3 never saw a duplicate. + +## What it cannot do + +- See the API's own symbols before they are compiled. That is why `POAX001` exists, and why its fix applies to the next + build rather than the current one. +- Fix a collision that never passes through `AdditionalFiles`. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md new file mode 100644 index 0000000..5fbcca6 --- /dev/null +++ b/docs/wiki/Release-Flow.md @@ -0,0 +1,18 @@ +# Release Flow + +- `package.json` is the authoritative version. `Purview.BuildSdk` reads it, so no project file carries a version. +- A pull request runs the shared `purview-dev/build` pipeline (restore, build, lint, every test tier, pack and pack + validation) and the packed-consumer gate. +- A push to `main` runs the release action with `Release:Mode=NuGet`: it packs, publishes to nuget.org through Trusted + Publishing, and creates the `v` GitHub release, only when that tag does not already exist. Bump + `package.json` and add a `CHANGELOG.md` entry to release. +- `.config/purview-build.json`'s `PackValidation.RequiredContent` is the exhaustive list of what the package ships; + `just pipeline-pack-validate` runs the same validation locally. + +To try a build in another repository before releasing it: + +```text +just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +``` + +Publish a new version each time: NuGet reuses an already-extracted package with the same id and version. diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md new file mode 100644 index 0000000..44bbf6c --- /dev/null +++ b/docs/wiki/Testing.md @@ -0,0 +1,25 @@ +# Testing + +Four layers, each covering what the one before cannot. + +| Layer | Where | Proves | +| --- | --- | --- | +| Unit | `src/tests/XmlComments.UnitTests` | The rules (ID normalisation, which IDs are removed, overlap detection) and both tasks, run in-process against a mocked `IBuildEngine`. | +| Integration | `src/tests/XmlComments.IntegrationTests` | Real consumer solutions, built with the real OpenAPI generator on `net10.0` and `net11.0`, importing the props and targets from source. Each API requests its own `/openapi/v1.json`. The same consumers are diagnosed with the skill's script, so its report stays accurate. | +| Repository | `src/tests/Repository.UnitTests` | Every authored skill conforms to the Agent Skills specification and carries the product prefix. | +| Packed consumer | `scripts/test-packed-consumer.ps1` | The `.nupkg` itself, through `PackageReference` alone: the `buildTransitive` import, the packed task path, and nothing left in the build output. | + +The integration suite includes `Build_GivenDeduplicationDisabled_ReproducesTheDuplicateKeyFailure`, which turns the +package off and asserts the generator still fails. It is what shows the other tests would notice a regression. + +```text +just test # every tier +just test-unit # unit + repository +just test-integration +just smoke-packed-consumer +``` + +The integration tier needs `pwsh` on the path for the script tests; GitHub's hosted runners have it. + +Every consumer compiles the same documented public attribute into the API and each library, which is what a source +generator's post-initialization output does. The tests therefore depend on no particular generator. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md new file mode 100644 index 0000000..b6466d5 --- /dev/null +++ b/docs/wiki/Troubleshooting.md @@ -0,0 +1,84 @@ +# Troubleshooting + +## Find what collides + +The repository ships a diagnostic script with the +[`purview-openapi-xmlcomments-duplicates`](Agent-Skills.md) skill. It asks MSBuild for exactly the documentation files +the OpenAPI generator reads, and reports every colliding ID, where each copy comes from, whether the current +configuration handles it, and what to add when it does not: + +```bash +pwsh skills/purview-openapi-xmlcomments-duplicates/scripts/find-duplicate-documentation-ids.ps1 -Project path/to/Api +``` + +From a checkout of this repository, `just find-duplicates path/to/Api` runs the same. Options: `-TargetFramework` for a +multi-targeted project, `-Configuration`, and `-Json` for machine-readable output. It exits `0` when nothing would throw, +`1` when something would, and `2` when it could not inspect the project. Build the API first so it can also check the +API's own documentation and the generated cache (`-p:EmitCompilerGeneratedFiles=true`). + +```text +Project: src/Api/Api.csproj (net10.0) +Generator present: True +Package installed: True (enabled: True) +Excluded IDs: none +Reference docs: 1 file(s) +Project docs read: True + +[NOT HANDLED] ReferenceAndProject T:Purview.Results.GenerateResultAttribute: Domain.xml + this project + +Next: +- Add to the API project (these are each in one reference and in the API itself, so they are not duplicates among the references): + + + + +Result: /openapi/v1.json would still throw. +``` + +| Finding | Meaning | Fix | +| --- | --- | --- | +| `BetweenReferences` | In two or more referenced projects' documentation | Install the package; automatic | +| `ReferenceAndProject` | In one referenced project and in the API itself | `PurviewOpenAPIXmlCommentExclude` (what [`POAX001`](Diagnostics.md) reports) | +| Generated cache key added more than once | The last build throws | Rebuild after changing the configuration | + +## Common situations + +**Still `500` after installing the package.** Rebuild: the generated cache is only regenerated on compile. If it +persists, run the script; an unhandled `ReferenceAndProject` finding needs the exclude it prints, and `POAX001` was +reported on the build that found it. + +**`POAX001` keeps appearing after adding the exclude.** Check the ID matches the warning exactly and that the item is +in the API project (or a `Directory.Build.props` that covers it), not in a library. + +**The script finds nothing, but the cache still has a duplicate.** Both copies come from the API's own compilation, not +from referenced documentation, so the package cannot help. Look the key up in the generated cache (each referenced +file's entries come first, the API's last) and remove the second declaration. + +**The duplicated type is real API.** Two referenced projects declare the same public type in the same namespace. The +package removes both copies' descriptions, which is safe but loses them; rename one, since consumers referencing both +see an ambiguous type anyway. + +**The package does not seem to run.** `dotnet msbuild path/to/Api/Api.csproj -getProperty:PurviewOpenAPIXmlCommentDeduplication` +should print `true`. A normal-verbosity build (`-v:n`) logs `OpenAPI XML comments: removed '...'` for every removal. It +only runs in a project that references `Microsoft.AspNetCore.OpenApi`. + +**A workaround removes the generator.** Delete it once the script reports nothing would throw: it drops every XML +description. The script lists any project file that looks like one. + +## By hand + +Without `pwsh`, the generated cache shows every key: + +```bash +dotnet build path/to/Api -p:EmitCompilerGeneratedFiles=true +grep -o 'cache\.Add(@"[^"]*"' path/to/Api/obj/Debug/*/generated/Microsoft.AspNetCore.OpenApi.SourceGenerators/*/OpenApiXmlCommentSupport.generated.cs | sort | uniq -d +``` + +and the documentation files the generator reads are + +```bash +dotnet msbuild path/to/Api/Api.csproj -t:ResolveReferences -getItem:AdditionalFiles -p:PurviewOpenAPIXmlCommentDeduplication=false +``` + +The skill's `references/diagnosing.md` walks through classifying the result, finding which generator injects the type, +and reading a binary log. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 0000000..5aeeb31 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,9 @@ +- [Getting Started](Getting-Started.md) +- [How It Works](How-It-Works.md) +- [Configuration](Configuration.md) +- [Diagnostics](Diagnostics.md) +- [Troubleshooting](Troubleshooting.md) +- [Agent Skills](Agent-Skills.md) +- [Testing](Testing.md) +- [Release Flow](Release-Flow.md) +- [Contributing](Contributing.md) diff --git a/global.json b/global.json new file mode 100644 index 0000000..fb52053 --- /dev/null +++ b/global.json @@ -0,0 +1,13 @@ +{ + "sdk": { + "version": "11.0.100-rc.3.26522.148", + "rollForward": "latestMajor", + "allowPrerelease": true + }, + "msbuild-sdks": { + "Purview.BuildSdk": "1.2.0" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..3a17df0 --- /dev/null +++ b/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..eba78f3 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "openapi-xmlcomments", + "version": "1.0.0-prerelease.1", + "license": "MIT", + "private": true, + "keywords": [], + "homepage": "https://purview.dev/projects/openapi-xmlcomments/", + "bugs": { + "url": "https://github.com/purview-dev/openapi-xmlcomments/issues" + }, + "author": { + "name": "Kieron Lanning", + "url": "https://kieronlanning.dev/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/purview-dev/openapi-xmlcomments.git" + }, + "scripts": { + "lint:slnx:check": "bun scripts/remove-slnx-guid-ids.ts check", + "lint:slnx:fix": "bun scripts/remove-slnx-guid-ids.ts fix" + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "typescript": "^7.0.2" + }, + "engines": { + "bun": ">=1.4.2" + }, + "packageManager": "bun@1.4.2" +} diff --git a/scripts/packed-consumer/Ambient.cs b/scripts/packed-consumer/Ambient.cs new file mode 100644 index 0000000..edfcdaf --- /dev/null +++ b/scripts/packed-consumer/Ambient.cs @@ -0,0 +1,9 @@ +namespace Ambient; + +/// An attribute a source generator would inject into every assembly. +/// +/// Compiled into the API and both libraries, documented and public, which is exactly what makes the OpenAPI +/// XML-comment cache add T:Ambient.InjectedAttribute three times without the package. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class InjectedAttribute : Attribute { } diff --git a/scripts/packed-consumer/Api/Api.csproj b/scripts/packed-consumer/Api/Api.csproj new file mode 100644 index 0000000..8679311 --- /dev/null +++ b/scripts/packed-consumer/Api/Api.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/scripts/packed-consumer/Api/Program.cs b/scripts/packed-consumer/Api/Program.cs new file mode 100644 index 0000000..f60f595 --- /dev/null +++ b/scripts/packed-consumer/Api/Program.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; + +// Serves /openapi/v1.json to itself and exits 0 only on a success status. Without the package, the first schema +// transform throws "An item with the same key has already been added" and the request answers 500. +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls("http://127.0.0.1:0"); +builder.Services.AddOpenApi(); + +var app = builder.Build(); +app.MapOpenApi(); +app.MapGet("/a", () => new Lib.A.WidgetA()); +app.MapGet("/b", () => new Lib.B.WidgetB()); + +await app.StartAsync(); +var address = app.Services.GetRequiredService().Features.Get()!.Addresses.First(); +using var client = new HttpClient { BaseAddress = new Uri(address) }; +using var response = await client.GetAsync("/openapi/v1.json"); +Console.WriteLine($"STATUS {(int)response.StatusCode}"); +Console.WriteLine(await response.Content.ReadAsStringAsync()); +await app.StopAsync(); + +return response.IsSuccessStatusCode ? 0 : 1; diff --git a/scripts/packed-consumer/Directory.Build.props b/scripts/packed-consumer/Directory.Build.props new file mode 100644 index 0000000..358d719 --- /dev/null +++ b/scripts/packed-consumer/Directory.Build.props @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + $(NoWarn);CS1591 + + true + + diff --git a/scripts/packed-consumer/Directory.Build.targets b/scripts/packed-consumer/Directory.Build.targets new file mode 100644 index 0000000..058246e --- /dev/null +++ b/scripts/packed-consumer/Directory.Build.targets @@ -0,0 +1 @@ + diff --git a/scripts/packed-consumer/Directory.Packages.props b/scripts/packed-consumer/Directory.Packages.props new file mode 100644 index 0000000..ee1b47e --- /dev/null +++ b/scripts/packed-consumer/Directory.Packages.props @@ -0,0 +1,6 @@ + + + + false + + diff --git a/scripts/packed-consumer/Lib.A/Lib.A.csproj b/scripts/packed-consumer/Lib.A/Lib.A.csproj new file mode 100644 index 0000000..2908585 --- /dev/null +++ b/scripts/packed-consumer/Lib.A/Lib.A.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/packed-consumer/Lib.A/WidgetA.cs b/scripts/packed-consumer/Lib.A/WidgetA.cs new file mode 100644 index 0000000..cfc7bb3 --- /dev/null +++ b/scripts/packed-consumer/Lib.A/WidgetA.cs @@ -0,0 +1,8 @@ +namespace Lib.A; + +/// Widget from library A. +public sealed class WidgetA +{ + /// The name of the widget from library A. + public string Name { get; set; } = "A"; +} diff --git a/scripts/packed-consumer/Lib.B/Lib.B.csproj b/scripts/packed-consumer/Lib.B/Lib.B.csproj new file mode 100644 index 0000000..2908585 --- /dev/null +++ b/scripts/packed-consumer/Lib.B/Lib.B.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/packed-consumer/Lib.B/WidgetB.cs b/scripts/packed-consumer/Lib.B/WidgetB.cs new file mode 100644 index 0000000..a25e61a --- /dev/null +++ b/scripts/packed-consumer/Lib.B/WidgetB.cs @@ -0,0 +1,8 @@ +namespace Lib.B; + +/// Widget from library B. +public sealed class WidgetB +{ + /// The name of the widget from library B. + public string Name { get; set; } = "B"; +} diff --git a/scripts/remove-slnx-guid-ids.ts b/scripts/remove-slnx-guid-ids.ts new file mode 100644 index 0000000..96ba801 --- /dev/null +++ b/scripts/remove-slnx-guid-ids.ts @@ -0,0 +1,117 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const GUID_ID_PATTERN = /Id="\{?[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}\}?"/g; +const GUID_ID_WITH_WHITESPACE_PATTERN = /\s+Id="\{?[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}\}?"/g; + +const EXCLUDED_DIRECTORIES = new Set([ + ".git", + "node_modules", + "bin", + "obj", + "artifacts", + "TestResults", + ".tools" +]); + +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, ".."); + +type Mode = "check" | "fix"; + +interface TextFile { + content: string; + hadBom: boolean; +} + +function usage(): string { + return [ + "Removes `Id=\"{GUID}\"` attributes from .slnx files.", + "", + "Usage:", + " bun scripts/remove-slnx-guid-ids.ts ", + "", + "Commands:", + " check Report every .slnx containing a GUID Id attribute (exit 1 if any found)", + " fix Remove the attributes in place" + ].join("\n"); +} + +function parseMode(arg: string | undefined): Mode { + if (arg === "check" || arg === "fix") { + return arg; + } + + console.log(usage()); + process.exit(arg === "--help" || arg === "-h" || arg === undefined ? 0 : 2); +} + +async function findSolutionFiles(directory: string): Promise { + const results: string[] = []; + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (!EXCLUDED_DIRECTORIES.has(entry.name)) { + results.push(...(await findSolutionFiles(fullPath))); + } + } else if (entry.isFile() && entry.name.endsWith(".slnx")) { + results.push(fullPath); + } + } + + return results; +} + +async function readText(filePath: string): Promise { + const raw = await readFile(filePath, "utf8"); + const hadBom = raw.charCodeAt(0) === 0xfeff; + return { content: hadBom ? raw.slice(1) : raw, hadBom }; +} + +function lineNumberOf(content: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) { + if (content.charCodeAt(i) === 10) { + line++; + } + } + return line; +} + +async function main(): Promise { + const mode = parseMode(process.argv[2]); + const solutionFiles = await findSolutionFiles(REPOSITORY_ROOT); + + let violations = 0; + + for (const filePath of solutionFiles) { + const { content, hadBom } = await readText(filePath); + + if (mode === "check") { + for (const match of content.matchAll(GUID_ID_PATTERN)) { + const line = lineNumberOf(content, match.index ?? 0); + console.error(`${filePath}:${line}: found GUID Id attribute ${match[0]}`); + violations++; + } + } else { + const matches = content.match(GUID_ID_PATTERN); + if (matches === null) { + continue; + } + + const updated = content.replace(GUID_ID_WITH_WHITESPACE_PATTERN, ""); + await writeFile(filePath, hadBom ? `\ufeff${updated}` : updated, "utf8"); + console.log(`${filePath}: removed ${matches.length} GUID Id attribute(s)`); + } + } + + if (mode === "check" && violations > 0) { + process.exitCode = 1; + } +} + +await main(); \ No newline at end of file diff --git a/scripts/restore-dotnet-tools.ps1 b/scripts/restore-dotnet-tools.ps1 new file mode 100644 index 0000000..5bbc78e --- /dev/null +++ b/scripts/restore-dotnet-tools.ps1 @@ -0,0 +1,79 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Restores the repository's .NET local tools (.config/dotnet-tools.json), retrying a failed attempt. + +.DESCRIPTION + `dotnet tool restore` can fail on a cold package cache, exit 1, and still leave every tool installed. The + .NET 11 SDK (rc.3) mis-attributes the command of a RID-specific tool to the entry beside it in the manifest: + dotnet-inspect 0.26.0 declares DotNetCliTool version 2 with RuntimeIdentifierPackages, and the purview.build + entry after it is then rejected with + + The command "purview-build" specified in the tool manifest file is not contained in the package with + Package Id purview.build. The commands contained in the package are dotnet-inspect. + + although Purview.Build 0.8.1 declares and restores correctly on its own. The + failed attempt has downloaded and extracted the packages by then, so the next attempt succeeds - the bug is + an ordering/state artefact of a cold cache, not a wrong manifest. + + Retrying is bounded and never hides a genuine failure: every attempt must fail before this script fails, and a + failed attempt's output is printed when none succeeds. It is quiet on success, because + scripts/test-pack-validation-rules.ps1 restores once per validation run. This is the only place the repository + restores its local tools, so `just` and CI behave the same way. + +.PARAMETER Attempts + Total attempts, including the first. Defaults to 3. + +.PARAMETER DelaySeconds + Seconds to wait between attempts. Defaults to 2. + +.PARAMETER ToolManifest + Manifest to restore. Defaults to the repository's .config/dotnet-tools.json. A caller that made its own copy - + scripts/test-pack-validation-rules.ps1 builds a scratch repository - passes that copy instead. + +.EXAMPLE + ./scripts/restore-dotnet-tools.ps1 + +.EXAMPLE + ./scripts/restore-dotnet-tools.ps1 -Attempts 5 -DelaySeconds 10 +#> +[CmdletBinding()] +param ( + [ValidateRange(1, 10)] + [int] $Attempts = 3, + [ValidateRange(0, 60)] + [int] $DelaySeconds = 2, + [string] $ToolManifest +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$manifest = $ToolManifest ? $ToolManifest : (Join-Path $repositoryRoot '.config/dotnet-tools.json') +if (-not (Test-Path $manifest)) { throw "No local tool manifest at '$manifest'." } + +# Each failed attempt's output is buffered, not streamed: the retry is expected once on a cold cache, and a caller +# such as scripts/test-pack-validation-rules.ps1 restores 27 times. A genuine failure still prints every attempt. +$failures = [System.Collections.Generic.List[string]]::new() +for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + $output = & dotnet tool restore --tool-manifest $manifest 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -eq 0) { + # A retried attempt leaves the failed attempt's 1 in $LASTEXITCODE; a caller run as a CI step exits with it. + $global:LASTEXITCODE = 0 + Write-Host 'Local tools restored.' -ForegroundColor Green + return + } + + $failures.Add(($output -join "`n")) + if ($attempt -lt $Attempts) { + Write-Host ( + "dotnet tool restore failed (attempt $attempt of $Attempts); retrying in $DelaySeconds s. " + + "The attempt still populates the package cache, which is what the next one needs - see this script's help." + ) -ForegroundColor Yellow + Start-Sleep -Seconds $DelaySeconds + } +} + +$failures | ForEach-Object { Write-Host $_ } +throw "dotnet tool restore failed after $Attempts attempt(s)." diff --git a/scripts/test-packed-consumer.ps1 b/scripts/test-packed-consumer.ps1 new file mode 100644 index 0000000..a6c2bb0 --- /dev/null +++ b/scripts/test-packed-consumer.ps1 @@ -0,0 +1,111 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves the packed Purview.OpenAPI.XmlComments package works for a real consumer, through PackageReference alone. + +.DESCRIPTION + The integration tests import the package's props and targets from source and point them at a built task assembly. + This script covers what they cannot: NuGet's buildTransitive import, the packed tasks/netstandard2.0 path the props + resolve, and the development-dependency shape. + + It packs the package to an isolated feed, copies scripts/packed-consumer out of the repository (so nothing in the + repository applies to it), restores it with an isolated package cache, then: + + 1. builds it and fails on POAX001 - two libraries carry the injected type, so it is a plain duplicate; + 2. asserts the generated XML-comment cache adds T:Ambient.InjectedAttribute exactly once; + 3. asserts the package left nothing in the build output (no task assembly, no MSBuild assembly); + 4. runs the API, which requests its own /openapi/v1.json, and asserts a 200 carrying both libraries' descriptions. + +.PARAMETER PackOutput + Folder to pack into. Defaults to a scratch folder. + +.PARAMETER Configuration + Configuration to pack. Defaults to Release. + +.EXAMPLE + ./scripts/test-packed-consumer.ps1 +#> +[CmdletBinding()] +param ( + [string] $PackOutput, + [string] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$scratch = Join-Path ([IO.Path]::GetTempPath()) "purview-openapi-xmlcomments-packed-$([Guid]::NewGuid().ToString('N'))" +# Absolute, because pack runs from the repository root and the consumer's nuget.config lives in the scratch folder. +$feed = $PackOutput ? $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($PackOutput) : (Join-Path $scratch 'feed') +$packages = Join-Path $scratch 'packages' +$consumer = Join-Path $scratch 'consumer' + +function Assert-That([bool] $condition, [string] $message) { + if (-not $condition) { throw "Packed consumer: $message" } + Write-Host " ok $message" -ForegroundColor Green +} + +function Invoke-DotNet([string] $workingDirectory, [string[]] $arguments) { + Push-Location $workingDirectory + try { + $output = & dotnet @arguments 2>&1 | ForEach-Object { "$_" } + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = ($output -join "`n") } + } + finally { Pop-Location } +} + +Write-Host "Packing Purview.OpenAPI.XmlComments $version ($Configuration) to $feed" +$pack = Invoke-DotNet $repositoryRoot @('pack', 'src/src/XmlComments/XmlComments.csproj', '-c', $Configuration, '-o', $feed) +if ($pack.ExitCode -ne 0) { Write-Host $pack.Output; throw 'dotnet pack failed.' } + +Copy-Item (Join-Path $PSScriptRoot 'packed-consumer') $consumer -Recurse +@" + + + + + + + + + + + + + + + + +"@ | Set-Content (Join-Path $consumer 'nuget.config') + +$previousPackages = $env:NUGET_PACKAGES +$env:NUGET_PACKAGES = $packages +try { + Write-Host 'Building the consumer' + $build = Invoke-DotNet $consumer @('build', 'Api/Api.csproj', '-nodeReuse:false', "-p:PurviewOpenAPIXmlCommentsVersion=$version") + if ($build.ExitCode -ne 0) { Write-Host $build.Output; throw 'The consumer build failed.' } + Assert-That ($build.Output -notmatch 'POAX001') 'the build reports no POAX001' + + $generated = Get-ChildItem (Join-Path $consumer 'Api/obj') -Recurse -Filter 'OpenApiXmlCommentSupport.generated.cs' | Select-Object -First 1 + Assert-That ($null -ne $generated) 'the OpenAPI XML-comment generator ran' + $entries = @(Select-String -Path $generated.FullName -Pattern 'cache\.Add\(@"T:Ambient\.InjectedAttribute"').Count + Assert-That ($entries -eq 1) "the cache adds T:Ambient.InjectedAttribute once (found $entries)" + + $output = Join-Path $consumer 'Api/bin/Debug/net10.0' + $leaked = @(Get-ChildItem $output -Filter '*.dll' | Where-Object { $_.Name -like 'Purview.OpenAPI.XmlComments*' -or $_.Name -like 'Microsoft.Build*' }) + Assert-That ($leaked.Count -eq 0) 'the package left no assembly in the build output' + + Write-Host 'Running the consumer' + $run = Invoke-DotNet (Join-Path $consumer 'Api') @("bin/Debug/net10.0/Api.dll") + if ($run.ExitCode -ne 0) { Write-Host $run.Output } + Assert-That ($run.ExitCode -eq 0) '/openapi/v1.json answers a success status' + Assert-That ($run.Output -match 'Widget from library A\.') "library A's description is in the document" + Assert-That ($run.Output -match 'Widget from library B\.') "library B's description is in the document" +} +finally { + $env:NUGET_PACKAGES = $previousPackages +} + +Write-Host "Packed consumer passed ($scratch)." -ForegroundColor Green diff --git a/skills/purview-openapi-xmlcomments-duplicates/SKILL.md b/skills/purview-openapi-xmlcomments-duplicates/SKILL.md new file mode 100644 index 0000000..e55ade6 --- /dev/null +++ b/skills/purview-openapi-xmlcomments-duplicates/SKILL.md @@ -0,0 +1,124 @@ +--- +name: purview-openapi-xmlcomments-duplicates +description: "Use when an ASP.NET Core OpenAPI document fails with \"ArgumentException: An item with the same key has already been added\" from XmlCommentCache.GenerateCacheEntries, /openapi/v1.json answers 500, the build warns POAX001, or a project removes the Microsoft.AspNetCore.OpenApi.SourceGenerators analyzer to avoid duplicate XML documentation members. Finds which documentation IDs collide and where each copy comes from, then installs and configures Purview.OpenAPI.XmlComments (PurviewOpenAPIXmlCommentExclude, PurviewOpenAPIXmlCommentDeduplication) and verifies the fix." +license: MIT +metadata: + package: Purview.OpenAPI.XmlComments + repository: purview-dev/openapi-xmlcomments +--- + +# Debugging duplicate OpenAPI XML-comment members + +The `Microsoft.AspNetCore.OpenApi` XML-comment source generator builds one dictionary with `Add` from two sources and +de-duplicates neither (dotnet/aspnetcore#67192): + +1. every `` of every **project-reference** XML documentation file (no accessibility filter - `internal` + members count); +2. every **public**, documented symbol of the API project's own compilation, including other source generators' + output. + +A documented type compiled into more than one of those - nearly always an attribute or polyfill that a source +generator injects into every assembly - is added twice and the cache throws on first use. `Purview.OpenAPI.XmlComments` +fixes it at build time without turning the generator off. + +## 1. Confirm it is this problem + +It is, when the exception or log shows: + +```text +System.ArgumentException: An item with the same key has already been added. Key: T:Some.Namespace.SomeType + at System.Collections.Generic.Dictionary`2.Add(...) + at Microsoft.AspNetCore.OpenApi.Generated....XmlCommentCache.GenerateCacheEntries() +``` + +The `Key:` is the documentation ID (`T:` type, `M:` method, `P:` property, `F:` field, `E:` event). A different +stack - for example a duplicate *schema* or *operation id* - is a different problem; stop here. + +Also treat these as the same problem: a build warning `POAX001`, or a project file containing +`` (that workaround drops every description). + +## 2. Find every colliding ID and its sources + +Run the bundled [scripts/find-duplicate-documentation-ids.ps1](scripts/find-duplicate-documentation-ids.ps1) +against the project that calls `AddOpenApi()`. It asks MSBuild for exactly the files the generator reads and reports +every collision, not just the first one the exception happened to name: + +```bash +pwsh scripts/find-duplicate-documentation-ids.ps1 -Project path/to/Api +# -TargetFramework net10.0 for a multi-targeted project; -Json for machine-readable output +``` + +Read its findings: + +| Finding | Meaning | Fix | +| --- | --- | --- | +| `BetweenReferences` | The ID is in two or more referenced projects' documentation. | Installing the package fixes it automatically. | +| `ReferenceAndProject` | The ID is in exactly one referenced project and in the API itself. | An exclude (step 3). This is what `POAX001` reports. | +| Generated cache key added more than once | The **last build** throws. | Rebuild after changing the configuration; still there means the copies are not coming from referenced XML (step 5). | + +The script exits `0` when nothing would throw with the current configuration, `1` when something would, `2` when it +could not inspect the project. Without `pwsh`, follow [references/diagnosing.md](references/diagnosing.md), which also +explains how to read the generated cache and a binary log by hand. + +## 3. Update the configuration + +Install the package in the API project (it is a development dependency and does nothing in a project without the +OpenAPI package): + +```xml + +``` + +Under Central Package Management add `` to +`Directory.Packages.props` and omit `Version` above, or use a `GlobalPackageReference` for every project. Use the +latest version from nuget.org; do not invent one. + +For every `ReferenceAndProject` finding, add the exclude the script printed: + +```xml + + + +``` + +Then remove any workaround that turns the generator off. Keep everything else - especially +`GenerateDocumentationFile` in the libraries, which is what produces the descriptions. + +Where each setting belongs (a single API, several APIs, `Directory.Build.props`), the CPM forms, warnings-as-errors +and turning the package off are in [references/configuration.md](references/configuration.md). + +## 4. Verify + +1. Rebuild the API (`dotnet build`). There must be no `POAX001`. +2. Run the script again: it must report `Result: nothing would throw` and exit `0`. +3. Request `/openapi/v1.json` and check it returns `200` with the expected descriptions. + +`POAX001` is found after compile, so an exclude added for it takes effect on the **next** build. + +## 5. When the package does not resolve it + +- **The cache still has a duplicate after a rebuild, and the script finds nothing in the referenced documentation.** + Both copies come from the API's own compilation, or from something other than a project reference's XML file + (for example an `AdditionalFiles` XML the project adds itself under a different shape). Find the key in the generated + cache (see [references/diagnosing.md](references/diagnosing.md)) and remove the second declaration. +- **The duplicated ID is real API surface** - two referenced projects each declare the same public type in the same + namespace. The package removes both copies' descriptions, which is safe but loses them; the real fix is a rename, + because the type is also ambiguous to every consumer referencing both. +- **`POAX001` for a type that is `internal` in the API.** The generator only reads the API's public symbols, so it does + not collide; the warning over-reports. Silence it with `POAX001` + or add the exclude anyway (harmless). + +## Do not + +- Remove the generator, or set `GenerateDocumentationFile=false` in a library - both lose descriptions. +- Make the injected type `internal` or `[Embedded]` expecting it to help: the compiler still writes internal members to + the XML documentation file, and the generator reads every member of it. +- Keep one copy of a duplicated reference ID by hand-editing XML in `bin/`: it is regenerated on every build, and the + API's own copy would still collide. +- Report the fix as done before step 4 passes. + +## For source-generator authors + +The durable fix in a generator that injects a type into every compilation is to emit it **without XML documentation +comments**: an undocumented member never reaches the XML file, so it never reaches the cache. `internal` and +`[Embedded]` are still good hygiene, but on their own do not stop the collision. diff --git a/skills/purview-openapi-xmlcomments-duplicates/references/configuration.md b/skills/purview-openapi-xmlcomments-duplicates/references/configuration.md new file mode 100644 index 0000000..110da00 --- /dev/null +++ b/skills/purview-openapi-xmlcomments-duplicates/references/configuration.md @@ -0,0 +1,107 @@ +# Configuring Purview.OpenAPI.XmlComments + +Every setting is MSBuild. The package's defaults are imported before the project body, so a value set in the project +or in `Directory.Build.props` wins. + +## Installing + +Into the project that calls `AddOpenApi()` - the only project that needs it: + +```xml + + + +``` + +With Central Package Management (`Directory.Packages.props`): + +```xml + + + +``` + +and `` in the API project. + +For every project in the repository at once (harmless where there is no OpenAPI package): + +```xml + + + + +``` + +It is a development dependency: nothing reaches the build output, and nothing flows to packages the project produces. +Check the latest version on nuget.org rather than guessing one. + +## Removing the generator workaround + +Delete whichever form the repository used - they all disable every XML description: + +```xml + + + + + + + + + +``` + +Search for it in the API project and every `Directory.Build.props`/`.targets` above it: + +```bash +grep -rn "OpenApi.SourceGenerators" --include=*.csproj --include=Directory.Build.* . +``` + +## Excluding IDs + +Needed only for an ID that is in exactly **one** referenced project and in the API itself (`POAX001`); IDs in two or +more referenced projects are handled with no configuration. + +```xml + + + +``` + +- Use the documentation ID exactly as `POAX001`, the script or the exception prints it. Method IDs may be written with + or without their `~ReturnType` suffix. +- Put it in the API project. With several API projects that share the same injected types, put it in a + `Directory.Build.props` that covers them; the item is ignored by projects without the OpenAPI package. +- An exclude removes the ID from the **referenced** documentation only; the API's own copy keeps its description. + +## Warnings + +`POAX001` is an ordinary MSBuild warning: + +```xml + + + $(MSBuildWarningsAsErrors);POAX001 + + $(MSBuildWarningsAsMessages);POAX001 + +``` + +## Turning it off + +```xml + + false + +``` + +Do this once the generator de-duplicates its own cache (dotnet/aspnetcore#67192), then remove the package. + +## Checking it is active + +```bash +dotnet msbuild path/to/Api/Api.csproj -getProperty:PurviewOpenAPIXmlCommentDeduplication -getProperty:PurviewOpenAPIXmlCommentsTaskAssembly +``` + +`true` and a path to `Purview.OpenAPI.XmlComments.dll` mean it is imported. A normal-verbosity build (`-v:n`) logs each +removal: `OpenAPI XML comments: removed 'T:...' from Lib.A, Lib.B (duplicate).` diff --git a/skills/purview-openapi-xmlcomments-duplicates/references/diagnosing.md b/skills/purview-openapi-xmlcomments-duplicates/references/diagnosing.md new file mode 100644 index 0000000..95efd8c --- /dev/null +++ b/skills/purview-openapi-xmlcomments-duplicates/references/diagnosing.md @@ -0,0 +1,96 @@ +# Diagnosing duplicate documentation IDs by hand + +Use this when `scripts/find-duplicate-documentation-ids.ps1` cannot run (no `pwsh`), or to confirm what it reported. + +## 1. See the generated cache + +Build the API with the generator's output written to disk: + +```bash +dotnet build path/to/Api -p:EmitCompilerGeneratedFiles=true +``` + +The cache is +`obj///generated/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator/OpenApiXmlCommentSupport.generated.cs`. +Each entry is one line: + +```csharp +cache.Add(@"T:Some.Namespace.SomeType", new XmlComment(@"The summary.", ...)); +``` + +List every key added more than once: + +```bash +# bash +grep -o 'cache\.Add(@"[^"]*"' OpenApiXmlCommentSupport.generated.cs | sort | uniq -d +``` + +```powershell +# PowerShell +Select-String -Path OpenApiXmlCommentSupport.generated.cs -Pattern 'cache\.Add\(@"([^"]*)"' -AllMatches | + ForEach-Object { $_.Matches.Groups[1].Value } | Group-Object | Where-Object Count -gt 1 +``` + +The entries appear in source order: one block per referenced documentation file, then a final block for the API's own +compilation. A key whose last copy is in the final block is one the API itself declares. + +## 2. See exactly which documentation files the generator reads + +The OpenAPI package's `GenerateAdditionalXmlFilesForOpenApi` target adds them to `AdditionalFiles` after +`ResolveReferences`. Ask MSBuild for them (with the package's own filtering off, to see the raw inputs): + +```bash +dotnet msbuild path/to/Api/Api.csproj -t:ResolveReferences -getItem:AdditionalFiles -p:PurviewOpenAPIXmlCommentDeduplication=false +``` + +Only `.xml` items from project references are documentation files; each is `/bin///.xml`. +Package references' documentation is **not** read, so a package cannot be a source. + +Find which of them declare a given ID: + +```bash +grep -l 'name="T:Some.Namespace.SomeType"' path/to/*/bin/Debug/net10.0/*.xml +``` + +The API's own documentation file (`obj///.xml`) shows whether the API declares it too. + +## 3. Classify + +| The ID is in | Source of the collision | Fix | +| --- | --- | --- | +| two or more reference XML files | references among themselves | install the package; automatic | +| one reference XML file and the API | the reference and the API's own compilation | install the package and add `PurviewOpenAPIXmlCommentExclude` | +| only the API, twice | the API's compilation alone | not a reference problem: find and remove the second declaration | + +Two IDs that differ only in a method's `~ReturnType` suffix are the same key: the generator strips the suffix (except +on `op_Implicit`/`op_Explicit`) before adding. + +## 4. Find who injects the type + +The colliding type is usually not in anyone's source. Look for it in the generator output of a referenced project: + +```bash +dotnet build path/to/Lib -p:EmitCompilerGeneratedFiles=true +grep -rl 'class SomeType' path/to/Lib/obj/Debug/*/generated/ +``` + +The folder name under `generated/` is the generator that emitted it. Typical culprits: opt-in attributes a generator +adds to every compilation (`Purview.Results.GenerateResultAttribute`), polyfills (`IsExternalInit`, +`RequiredMemberAttribute`, nullable attributes) carrying doc comments, and shared files linked into several projects +with ``. + +## 5. Use a binary log when the evidence disagrees + +```bash +dotnet build path/to/Api -bl -p:EmitCompilerGeneratedFiles=true +``` + +Open `msbuild.binlog` in the MSBuild Structured Log Viewer and search for: + +- `GenerateAdditionalXmlFilesForOpenApi` - the `AdditionalFiles` it added; +- `PurviewDeduplicateOpenAPIXmlComments` - the `OpenAPI XML comments: removed '...' from ... (duplicate|excluded)` + messages, and the filtered copies swapped in from `obj/.../purview-openapi-xml/`; +- `Csc` - the `AdditionalFiles` the compiler actually received. + +If `PurviewDeduplicateOpenAPIXmlComments` is absent, the package is not imported into that project (check the +`PackageReference`, and that `PurviewOpenAPIXmlCommentDeduplication` is not `false`). diff --git a/skills/purview-openapi-xmlcomments-duplicates/scripts/find-duplicate-documentation-ids.ps1 b/skills/purview-openapi-xmlcomments-duplicates/scripts/find-duplicate-documentation-ids.ps1 new file mode 100644 index 0000000..30907fb --- /dev/null +++ b/skills/purview-openapi-xmlcomments-duplicates/scripts/find-duplicate-documentation-ids.ps1 @@ -0,0 +1,303 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Finds the documentation IDs that make the Microsoft.AspNetCore.OpenApi XML-comment cache throw "An item with the same + key has already been added", says where each one comes from, and prints the configuration that fixes it. + +.DESCRIPTION + The generator adds, with Dictionary.Add, every of every project-reference XML documentation file in + AdditionalFiles, and every public documented symbol of the API's own compilation. This script asks MSBuild for exactly + the files the generator reads (with Purview.OpenAPI.XmlComments' filtering turned off, so the raw inputs are seen), + then: + + - lists every ID that occurs in two or more referenced documentation files ("BetweenReferences"); + - lists every ID that occurs in one referenced file and in the API's own documentation file + ("ReferenceAndProject" - what POAX001 reports; needs the API to have been built); + - reads the generated cache, when the API was built with EmitCompilerGeneratedFiles=true, and lists every key + added more than once - the definitive answer to "will it throw"; + - reports whether the generator is present, whether Purview.OpenAPI.XmlComments is installed and enabled, the IDs + already excluded, and any project file that looks like it removes the generator; + - says, for each finding, whether the current configuration already handles it, and prints what to add when not. + + Run it from, or point it at, the project that calls AddOpenApi(). It restores and evaluates the project and builds its + project references (ResolveReferences does), but does not compile the project itself. + +.PARAMETER Project + The API project: a .csproj file or the folder holding one. Defaults to the current folder. + +.PARAMETER TargetFramework + The target framework to inspect, for a multi-targeted project. Defaults to the project's TargetFramework, or the first + of its TargetFrameworks. + +.PARAMETER Configuration + The build configuration. Defaults to Debug. + +.PARAMETER Json + Writes the report as JSON instead of text. + +.OUTPUTS + Exit code 0 when nothing would throw with the current configuration, 1 when something would, 2 when the project + could not be inspected. + +.EXAMPLE + pwsh ./find-duplicate-documentation-ids.ps1 -Project src/Api + +.EXAMPLE + pwsh ./find-duplicate-documentation-ids.ps1 -Project src/Api/Api.csproj -TargetFramework net10.0 -Json +#> +[CmdletBinding()] +param ( + [string] $Project = '.', + [string] $TargetFramework, + [string] $Configuration = 'Debug', + [switch] $Json +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-ProjectFile([string] $path) { + $item = Get-Item -LiteralPath $path + if (-not $item.PSIsContainer) { return $item.FullName } + $candidates = @(Get-ChildItem -LiteralPath $item.FullName -Filter '*.csproj' -File) + if ($candidates.Count -ne 1) { + throw "Expected one .csproj in '$($item.FullName)', found $($candidates.Count). Pass -Project .csproj." + } + return $candidates[0].FullName +} + +function Invoke-MSBuildQuery([string] $projectFile, [string[]] $arguments) { + $output = & dotnet msbuild $projectFile -nologo -nodeReuse:false "-p:Configuration=$Configuration" @arguments 2>&1 | + ForEach-Object { "$_" } + $text = $output -join "`n" + if ($LASTEXITCODE -ne 0) { throw "dotnet msbuild failed for '$projectFile':`n$text" } + $start = $text.IndexOf('{') + if ($start -lt 0) { throw "dotnet msbuild returned no JSON for '$projectFile':`n$text" } + return $text.Substring($start) | ConvertFrom-Json +} + +# Mirrors XmlCommentGenerator.NormalizeDocId, the generator's cache key. +function Get-NormalizedId([string] $id) { + $tilde = $id.IndexOf('~') + if ($tilde -lt 0 -or -not $id.StartsWith('M:') -or $id.Contains('.op_Implicit(') -or $id.Contains('.op_Explicit(')) { + return $id + } + return $id.Substring(0, $tilde) +} + +# The normalised IDs of a compiler XML documentation file, or $null when the file is not one. +function Get-DocumentationIds([string] $path) { + if (-not (Test-Path -LiteralPath $path)) { return $null } + try { [xml] $document = Get-Content -LiteralPath $path -Raw } catch { return $null } + if ($null -eq $document.DocumentElement -or $document.DocumentElement.LocalName -ne 'doc') { return $null } + $members = $document.SelectNodes('/doc/members/member[@name]') + if ($null -eq $document.SelectSingleNode('/doc/members')) { return $null } + return @($members | ForEach-Object { Get-NormalizedId $_.GetAttribute('name') }) +} + +function Get-Items($query, [string] $name) { + if ($query.PSObject.Properties.Name -notcontains 'Items') { return @() } + if ($query.Items.PSObject.Properties.Name -notcontains $name) { return @() } + return @($query.Items.$name) +} + +try { + $projectFile = Resolve-ProjectFile $Project + $projectDirectory = Split-Path -Parent $projectFile + + $frameworkArguments = @() + $evaluation = Invoke-MSBuildQuery $projectFile @( + '-restore', + '-getProperty:TargetFramework', '-getProperty:TargetFrameworks' + ) + $framework = $TargetFramework + if (-not $framework) { $framework = $evaluation.Properties.TargetFramework } + if (-not $framework) { $framework = ($evaluation.Properties.TargetFrameworks -split ';' | Where-Object { $_ })[0] } + if (-not $framework) { throw "'$projectFile' declares no TargetFramework." } + $frameworkArguments = @("-p:TargetFramework=$framework") + + $settings = Invoke-MSBuildQuery $projectFile (@( + '-getProperty:PurviewOpenAPIXmlCommentsTaskAssembly', + '-getProperty:PurviewOpenAPIXmlCommentDeduplication', + '-getProperty:IntermediateOutputPath', + '-getProperty:GenerateDocumentationFile', + '-getItem:PurviewOpenAPIXmlCommentExclude', + '-getItem:DocFileItem' + ) + $frameworkArguments) + + + $packageInstalled = [bool] $settings.Properties.PurviewOpenAPIXmlCommentsTaskAssembly + $packageEnabled = $packageInstalled -and $settings.Properties.PurviewOpenAPIXmlCommentDeduplication -eq 'true' + $excluded = @(Get-Items $settings 'PurviewOpenAPIXmlCommentExclude' | ForEach-Object { Get-NormalizedId $_.Identity }) + + # The raw inputs: the package's filtering is turned off for this query only. + $references = Invoke-MSBuildQuery $projectFile (@( + '-t:ResolveReferences', + '-getItem:AdditionalFiles', + '-getItem:Analyzer', + '-p:PurviewOpenAPIXmlCommentDeduplication=false' + ) + $frameworkArguments) + + # Package analyzers are only added once package assets are resolved, so this is read after ResolveReferences. + $generatorPresent = @(Get-Items $references 'Analyzer' | + Where-Object { $_.Filename -eq 'Microsoft.AspNetCore.OpenApi.SourceGenerators' }).Count -gt 0 + + $referenceDocumentation = [ordered] @{} + foreach ($file in Get-Items $references 'AdditionalFiles' | Where-Object { $_.Extension -eq '.xml' }) { + $ids = Get-DocumentationIds $file.FullPath + if ($null -ne $ids) { $referenceDocumentation[$file.FullPath] = $ids } + } + + $sources = @{} + foreach ($path in $referenceDocumentation.Keys) { + foreach ($id in $referenceDocumentation[$path]) { + if (-not $sources.ContainsKey($id)) { $sources[$id] = [System.Collections.Generic.List[string]]::new() } + $sources[$id].Add([IO.Path]::GetFileName($path)) + } + } + + $docFile = @(Get-Items $settings 'DocFileItem' | Select-Object -First 1) + $projectDocumentationPath = $docFile.Count -gt 0 ? $docFile[0].FullPath : $null + $projectIds = $projectDocumentationPath ? (Get-DocumentationIds $projectDocumentationPath) : $null + $projectIdSet = [System.Collections.Generic.HashSet[string]]::new([string[]] @($projectIds ?? @())) + + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($id in ($sources.Keys | Sort-Object { $_ } -CaseSensitive)) { + $count = $sources[$id].Count + if ($count -gt 1) { + $findings.Add([pscustomobject] @{ + Id = $id; Kind = 'BetweenReferences'; Sources = @($sources[$id]); InProject = $projectIdSet.Contains($id) + Handled = $packageEnabled + }) + } + elseif ($projectIdSet.Contains($id)) { + $findings.Add([pscustomobject] @{ + Id = $id; Kind = 'ReferenceAndProject'; Sources = @($sources[$id]); InProject = $true + Handled = $packageEnabled -and $excluded -contains $id + }) + } + } + + $cache = $null + $intermediate = Join-Path $projectDirectory $settings.Properties.IntermediateOutputPath + $generated = if (Test-Path -LiteralPath $intermediate) { + Get-ChildItem -LiteralPath $intermediate -Recurse -Filter 'OpenApiXmlCommentSupport.generated.cs' -File | + Select-Object -First 1 + } + if ($generated) { + $keys = [regex]::Matches((Get-Content -LiteralPath $generated.FullName -Raw), 'cache\.Add\(@"((?:[^"]|"")*)"') | + ForEach-Object { $_.Groups[1].Value.Replace('""', '"') } + $cache = [pscustomobject] @{ + Path = $generated.FullName + Written = $generated.LastWriteTime.ToString('s') + DuplicateKeys = @($keys | Group-Object -CaseSensitive | Where-Object Count -gt 1 | + ForEach-Object { [pscustomobject] @{ Id = $_.Name; Count = $_.Count } }) + } + } + + $workarounds = [System.Collections.Generic.List[string]]::new() + $directory = Get-Item -LiteralPath $projectDirectory + $scanned = [System.Collections.Generic.List[string]]::new() + $scanned.Add($projectFile) + while ($null -ne $directory) { + foreach ($name in 'Directory.Build.props', 'Directory.Build.targets') { + $candidate = Join-Path $directory.FullName $name + if (Test-Path -LiteralPath $candidate) { $scanned.Add($candidate) } + } + $directory = $directory.Parent + } + foreach ($file in $scanned) { + Select-String -LiteralPath $file -Pattern 'Microsoft\.AspNetCore\.OpenApi\.SourceGenerators' | + Where-Object { $_.Line -match 'Remove' } | + ForEach-Object { $workarounds.Add("$($_.Path):$($_.LineNumber)") } + } + + $recommendations = [System.Collections.Generic.List[string]]::new() + if (-not $generatorPresent) { + $recommendations.Add('The project does not reference Microsoft.AspNetCore.OpenApi, so its XML-comment generator cannot fail here.') + } + if ($workarounds.Count -gt 0) { + $recommendations.Add("Remove the generator workaround at $($workarounds -join ', ') once the findings below are handled: it drops every XML description.") + } + $unhandled = @($findings | Where-Object { -not $_.Handled }) + if (-not $packageInstalled -and $findings.Count -gt 0) { + $recommendations.Add('Install Purview.OpenAPI.XmlComments in this project: ') + } + elseif ($packageInstalled -and -not $packageEnabled -and $findings.Count -gt 0) { + $recommendations.Add('Purview.OpenAPI.XmlComments is installed but PurviewOpenAPIXmlCommentDeduplication is not true; remove the override.') + } + $needExclude = @($findings | Where-Object { $_.Kind -eq 'ReferenceAndProject' -and $excluded -notcontains $_.Id }) + if ($needExclude.Count -gt 0) { + $items = ($needExclude | ForEach-Object { "`t" }) -join "`n" + $recommendations.Add("Add to the API project (these are each in one reference and in the API itself, so they are not duplicates among the references):`n`n$items`n") + } + if ($null -eq $projectIds) { + $recommendations.Add('The API has not been built (or does not generate documentation), so IDs shared with its own compilation could not be checked. Build it and run this again.') + } + if ($null -eq $cache -and $generatorPresent) { + $recommendations.Add('No generated cache was found. Build with -p:EmitCompilerGeneratedFiles=true to confirm the result against the cache itself.') + } + $cacheDuplicates = $cache ? @($cache.DuplicateKeys) : @() + if ($cacheDuplicates.Count -gt 0) { + $recommendations.Add("The generated cache from the last build adds $($cacheDuplicates.Count) key(s) more than once, so that build throws. Rebuild after changing the configuration; if it persists, the colliding copies are not coming from referenced XML files.") + } + + $report = [pscustomobject] @{ + Project = $projectFile + TargetFramework = $framework + GeneratorPresent = $generatorPresent + PackageInstalled = $packageInstalled + PackageEnabled = $packageEnabled + Excluded = $excluded + Workarounds = @($workarounds) + ReferenceDocumentation = @($referenceDocumentation.Keys) + ProjectDocumentation = $projectDocumentationPath + ProjectDocumentationRead = $null -ne $projectIds + Findings = @($findings) + Cache = $cache + WouldThrow = ($unhandled.Count -gt 0) -or ($cacheDuplicates.Count -gt 0) + Recommendations = @($recommendations) + } + + if ($Json) { + $report | ConvertTo-Json -Depth 6 + } + else { + Write-Host "Project: $($report.Project) ($framework)" + Write-Host "Generator present: $generatorPresent" + Write-Host "Package installed: $packageInstalled$($packageInstalled ? " (enabled: $packageEnabled)" : '')" + Write-Host "Excluded IDs: $(@($excluded).Count -gt 0 ? ($excluded -join ', ') : 'none')" + Write-Host "Reference docs: $(@($report.ReferenceDocumentation).Count) file(s)" + Write-Host "Project docs read: $($report.ProjectDocumentationRead)" + Write-Host '' + if ($findings.Count -eq 0) { + Write-Host 'No documentation ID reaches the generator twice from the referenced documentation.' -ForegroundColor Green + } + foreach ($finding in $findings) { + $state = $finding.Handled ? 'handled' : 'NOT HANDLED' + $colour = $finding.Handled ? 'Green' : 'Red' + $also = $finding.InProject ? ' + this project' : '' + Write-Host ("[{0}] {1} {2}: {3}{4}" -f $state, $finding.Kind, $finding.Id, ($finding.Sources -join ', '), $also) -ForegroundColor $colour + } + if ($cache) { + Write-Host '' + Write-Host "Generated cache ($($cache.Written)): $($cache.Path)" + if ($cacheDuplicates.Count -eq 0) { Write-Host ' every key is added once' -ForegroundColor Green } + foreach ($key in $cacheDuplicates) { Write-Host " $($key.Id) is added $($key.Count) times" -ForegroundColor Red } + } + if ($recommendations.Count -gt 0) { + Write-Host '' + Write-Host 'Next:' + foreach ($recommendation in $recommendations) { Write-Host "- $recommendation" } + } + Write-Host '' + Write-Host ($report.WouldThrow ? 'Result: /openapi/v1.json would still throw.' : 'Result: nothing would throw with the current configuration.') -ForegroundColor ($report.WouldThrow ? 'Red' : 'Green') + } + + exit ($report.WouldThrow ? 1 : 0) +} +catch { + # Not Write-Error: under ErrorActionPreference=Stop it rethrows, and the documented exit code 2 becomes 1. + [Console]::Error.WriteLine("find-duplicate-documentation-ids: $($_.Exception.Message) (line $($_.InvocationInfo.ScriptLineNumber))") + exit 2 +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..395904b --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,63 @@ + + + Purview.OpenAPI + https://github.com/purview-dev/openapi-xmlcomments + + + Supported + + + universal + + + true + true + + + + + + + https://purview.dev/ + $(PurviewHomepage)projects/openapi-xmlcomments/ + $(PurviewHomepage)docs/openapi-xmlcomments/ + + + + Kieron Lanning + Purview-Dev + Copyright © $(CurrentYear) Purview-Dev + $(PurviewProjectUrl) + git + true + purview-logo-light.png + README.md + MIT + false + + + + + + + diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 0000000..f5fc4f1 --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,12 @@ + + + + + + true + + diff --git a/src/XmlComments.slnx b/src/XmlComments.slnx new file mode 100644 index 0000000..5821b8b --- /dev/null +++ b/src/XmlComments.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings.VisualStudio.json b/src/settings.VisualStudio.json new file mode 100644 index 0000000..194ba35 --- /dev/null +++ b/src/settings.VisualStudio.json @@ -0,0 +1,4 @@ +/* Visual Studio Settings File */ +{ + "csharpier.general.runonsave": true +} diff --git a/src/src/XmlComments/CheckOpenAPIXmlCommentOverlap.cs b/src/src/XmlComments/CheckOpenAPIXmlCommentOverlap.cs new file mode 100644 index 0000000..4a659ec --- /dev/null +++ b/src/src/XmlComments/CheckOpenAPIXmlCommentOverlap.cs @@ -0,0 +1,77 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Framework; + +namespace Purview.OpenAPI.XmlComments; + +/// +/// Warns when a documentation ID is documented both in the project and in a documentation file the OpenAPI +/// XML-comment generator reads, which the deduplication run before compile cannot see. +/// +/// +/// Run by the PurviewCheckOpenAPIXmlCommentOverlap target after CoreCompile, against the project's +/// freshly written documentation file. The fix it suggests, PurviewOpenAPIXmlCommentExclude, removes the +/// reference's copy on the next build. +/// +[SuppressMessage( + "Performance", + "CA1819:Properties should not return arrays", + Justification = "MSBuild binds item parameters to ITaskItem[] only." +)] +public sealed class CheckOpenAPIXmlCommentOverlap : Microsoft.Build.Utilities.Task +{ + /// + /// The warning code for an ID documented in both the project and a reference. + /// + public const string OverlapWarningCode = "POAX001"; + + /// + /// Gets or sets the path of the project's own XML documentation file (@(DocFileItem)). + /// + [Required] + public string ProjectDocumentation { get; set; } = string.Empty; + + /// + /// Gets or sets the documentation files the generator reads, after deduplication. + /// + [Required] + public ITaskItem[] ReferenceDocumentation { get; set; } = []; + + /// + public override bool Execute() + { + var project = XmlDocumentation.Load(ProjectDocumentation); + if (project is null) + { + return true; + } + + List references = []; + foreach (var item in ReferenceDocumentation) + { + var documentation = XmlDocumentation.Load(item.GetMetadata("FullPath")); + if (documentation is not null) + { + references.Add(documentation); + } + } + + foreach (var overlap in XmlCommentDeduplicator.FindProjectOverlap(project, references)) + { + Log.LogWarning( + subcategory: null, + warningCode: OverlapWarningCode, + helpKeyword: null, + file: null, + lineNumber: 0, + columnNumber: 0, + endLineNumber: 0, + endColumnNumber: 0, + message: "'{0}' is documented both in this project and in {1}.xml. If this project's copy is public, the OpenAPI XML-comment cache throws \"An item with the same key has already been added\" at run time; add to drop the reference's copy.", + overlap.Key, + overlap.Value + ); + } + + return !Log.HasLoggedErrors; + } +} diff --git a/src/src/XmlComments/DeduplicateOpenAPIXmlComments.cs b/src/src/XmlComments/DeduplicateOpenAPIXmlComments.cs new file mode 100644 index 0000000..d4f22af --- /dev/null +++ b/src/src/XmlComments/DeduplicateOpenAPIXmlComments.cs @@ -0,0 +1,133 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Purview.OpenAPI.XmlComments; + +/// +/// Writes copies of the documentation files the OpenAPI XML-comment generator reads with every colliding +/// documentation ID removed, so its generated cache never adds the same key twice. +/// +/// +/// Run by the PurviewDeduplicateOpenAPIXmlComments target after the OpenAPI package's +/// GenerateAdditionalXmlFilesForOpenApi target, which swaps out of +/// AdditionalFiles and in. A file with nothing to remove is not copied. +/// +[SuppressMessage( + "Performance", + "CA1819:Properties should not return arrays", + Justification = "MSBuild binds item parameters to ITaskItem[] only." +)] +public sealed class DeduplicateOpenAPIXmlComments : Microsoft.Build.Utilities.Task +{ + /// + /// Gets or sets the candidate files: the .xml items in AdditionalFiles. Anything that is not an XML + /// documentation file is ignored. + /// + [Required] + public ITaskItem[] Files { get; set; } = []; + + /// + /// Gets or sets documentation IDs to remove even when they occur once (@(PurviewOpenAPIXmlCommentExclude)). + /// + public ITaskItem[] Exclude { get; set; } = []; + + /// + /// Gets or sets the absolute directory the filtered copies are written to. + /// + [Required] + public string OutputDirectory { get; set; } = string.Empty; + + /// + /// Gets the items from that were copied, so they can be removed from AdditionalFiles. + /// + [Output] + public ITaskItem[] Replaced { get; private set; } = []; + + /// + /// Gets the filtered copies, in the same order as . + /// + [Output] + public ITaskItem[] Replacements { get; private set; } = []; + + /// + public override bool Execute() + { + List items = []; + List files = []; + foreach (var item in Files) + { + var documentation = XmlDocumentation.Load(item.GetMetadata("FullPath")); + if (documentation is not null) + { + items.Add(item); + files.Add(documentation); + } + } + + // Counted per file rather than matched by name, because two references can share a file name. + var memberCounts = files.Select(file => file.Members.Count()).ToList(); + var removed = XmlCommentDeduplicator.Deduplicate(files, Exclude.Select(item => item.ItemSpec)); + foreach (var member in removed) + { + Log.LogMessage( + MessageImportance.Normal, + "OpenAPI XML comments: removed '{0}' from {1} ({2}).", + member.Id, + string.Join(", ", member.Sources), + member.Reason == RemovalReason.Excluded ? "excluded" : "duplicate" + ); + } + + List replaced = []; + List replacements = []; + HashSet fileNames = new(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < files.Count; i++) + { + if (files[i].Members.Count() == memberCounts[i]) + { + continue; + } + + var path = Path.Combine(OutputDirectory, UniqueFileName(files[i].Name, fileNames)); + WriteIfChanged(path, files[i].ToFileText()); + + replaced.Add(items[i]); + replacements.Add(new TaskItem(path)); + } + + Replaced = [.. replaced]; + Replacements = [.. replacements]; + + return !Log.HasLoggedErrors; + } + + /// + /// Two references can share a file name (the same assembly name from different folders), so a later one gets a + /// numeric suffix rather than overwriting the first. + /// + static string UniqueFileName(string name, HashSet used) + { + var fileName = name + ".xml"; + for (var i = 1; !used.Add(fileName); i++) + { + fileName = $"{name}.{i}.xml"; + } + + return fileName; + } + + /// + /// The copies are compiler inputs, so a fresh timestamp on every build would defeat incremental compilation. A copy + /// is only written when its content changes. + /// + static void WriteIfChanged(string path, string text) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + if (!File.Exists(path) || File.ReadAllText(path) != text) + { + File.WriteAllText(path, text); + } + } +} diff --git a/src/src/XmlComments/DocumentationId.cs b/src/src/XmlComments/DocumentationId.cs new file mode 100644 index 0000000..c5976cf --- /dev/null +++ b/src/src/XmlComments/DocumentationId.cs @@ -0,0 +1,34 @@ +namespace Purview.OpenAPI.XmlComments; + +/// +/// Documentation-comment IDs as the OpenAPI XML-comment generator keys them. +/// +static class DocumentationId +{ + /// + /// Normalises the way XmlCommentGenerator.NormalizeDocId does before using it as a + /// cache key: a method's ~ReturnType suffix is removed, except on conversion operators, whose return type + /// is what tells their overloads apart. + /// + /// The documentation-comment ID, for example M:Ns.Type.Method(System.Int32)~System.String. + /// The ID the generator would use as its cache key. + /// + /// Duplicates are judged on this form, because two IDs that differ only in a stripped suffix still collide in the + /// generator's dictionary. + /// + public static string Normalize(string id) + { + var tilde = id.IndexOf('~'); + if ( + tilde < 0 + || !id.StartsWith("M:", StringComparison.Ordinal) + || id.Contains(".op_Implicit(") + || id.Contains(".op_Explicit(") + ) + { + return id; + } + + return id.Substring(0, tilde); + } +} diff --git a/src/src/XmlComments/Sdk/README.md b/src/src/XmlComments/Sdk/README.md new file mode 100644 index 0000000..f238df8 --- /dev/null +++ b/src/src/XmlComments/Sdk/README.md @@ -0,0 +1,118 @@ +# Purview.OpenAPI.XmlComments + +Stops the `Microsoft.AspNetCore.OpenApi` XML-comment source generator from failing at run time with + +```text +System.ArgumentException: An item with the same key has already been added. Key: T:Some.Namespace.SomeType + at ...XmlCommentCache.GenerateCacheEntries() +``` + +when the same documentation ID comes from more than one assembly, without turning the generator off, so every XML +description stays in your OpenAPI document. + +## The problem + +The generator builds one `Dictionary` with `Add` from two sources, and de-duplicates neither +([dotnet/aspnetcore#67192](https://github.com/dotnet/aspnetcore/issues/67192)): + +1. every `` in the XML documentation file of each **project reference**, with no accessibility filter; +2. every **public**, documented symbol of the API project's own compilation, including other source generators' + output. + +A type that a source generator or polyfill injects into every assembly, for example an opt-in attribute such as +`Purview.Results.GenerateResultAttribute`, therefore arrives once per referenced project and once more from the API +itself. `/openapi/v1.json` then answers `500`. The usual workaround, removing the generator, also removes every +description from the document. + +## Install + +Reference the package from the project that calls `AddOpenApi()`: + +```xml + +``` + +Or reference it once for every project, since it does nothing in a project without `Microsoft.AspNetCore.OpenApi`: + +```xml + + +``` + +It is a development dependency: nothing is added to your build output, and nothing flows to packages you produce. +Remove any `` workaround. + +## What it does + +Before the API compiles, right after the OpenAPI package adds the project-reference documentation files to +`AdditionalFiles`, it: + +- reads every XML documentation file in `AdditionalFiles`; +- finds each documentation ID that occurs more than once, judged after the same `~ReturnType` normalisation the + generator applies, and each ID listed in `PurviewOpenAPIXmlCommentExclude`; +- removes **every** occurrence of those IDs, writes filtered copies to `obj///purview-openapi-xml/`, + and swaps the copies into `AdditionalFiles`. + +It removes every occurrence, not all but the first, because the API usually declares the same injected type too: the +API's own copy is then the only one left. A duplicated ID is ambient code (an injected attribute, a polyfill), not API +surface, so no endpoint or schema description is lost. A filtered copy is only rewritten when its content changes, so +incremental builds still skip compilation. + +The project's own contribution is only known once it compiles. After compile, an ID still documented both in the API +and in one reference raises **`POAX001`**: + +```text +warning POAX001: 'T:Ambient.InjectedAttribute' is documented both in this project and in Lib.A.xml. If this +project's copy is public, the OpenAPI XML-comment cache throws "An item with the same key has already been added" at +run time; add to drop the reference's copy. +``` + +This happens when only **one** referenced project carries the injected type, because with two or more it is already a +duplicate. Add the exclude it names. `POAX001` can over-report: an overlap only fails when the API's own copy is +public. + +## Configuration + +| Setting | Default | Effect | +| --- | --- | --- | +| `false` | `true` | Turns the package off. | +| `` | none | Always removes that ID from the referenced documentation, even when it occurs once. Normalised like every other ID. | + +`POAX001` is an ordinary MSBuild warning: `POAX001` promotes it, +and `POAX001` silences it. + +The details of every decision are logged at normal verbosity, for example +`OpenAPI XML comments: removed 'T:Ambient.InjectedAttribute' from Lib.A, Lib.B (duplicate).` + +## Troubleshooting + +The exception names only the first colliding ID. A diagnostic script in the repository lists every one, where each +copy comes from, whether the current configuration handles it, and the configuration to add when it does not: + +```bash +# from skills/purview-openapi-xmlcomments-duplicates/scripts/ in the repository, or the installed skill's scripts/ folder +pwsh find-duplicate-documentation-ids.ps1 -Project path/to/Api +``` + +It is not in the package. It ships with the `purview-openapi-xmlcomments-duplicates` agent skill, which also gives AI coding agents the full +diagnose-configure-verify procedure: + +```bash +npx skills add purview-dev/openapi-xmlcomments +``` + +See (Troubleshooting) for the script's output and common situations. + +## Limitations + +- It filters only what reaches the generator as XML documentation files. Two public types with the same ID declared in + the API project itself are a compiler problem (`CS0101`), not this one. +- `POAX001` is reported after compile, so an overlap it finds is fixed by the exclude on the next build, not the + current one. +- It works around a generator defect. Once the generator de-duplicates its cache itself (dotnet/aspnetcore#67192), the + package can be removed. + +## More + +Documentation: . Source and issues: +. diff --git a/src/src/XmlComments/Sdk/buildTransitive/Purview.OpenAPI.XmlComments.props b/src/src/XmlComments/Sdk/buildTransitive/Purview.OpenAPI.XmlComments.props new file mode 100644 index 0000000..ff78caa --- /dev/null +++ b/src/src/XmlComments/Sdk/buildTransitive/Purview.OpenAPI.XmlComments.props @@ -0,0 +1,18 @@ + + + + true + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../tasks/netstandard2.0/Purview.OpenAPI.XmlComments.dll')) + + diff --git a/src/src/XmlComments/Sdk/buildTransitive/Purview.OpenAPI.XmlComments.targets b/src/src/XmlComments/Sdk/buildTransitive/Purview.OpenAPI.XmlComments.targets new file mode 100644 index 0000000..d1cb0c3 --- /dev/null +++ b/src/src/XmlComments/Sdk/buildTransitive/Purview.OpenAPI.XmlComments.targets @@ -0,0 +1,78 @@ + + + + + + + + + <_PurviewOpenAPIXmlCommentsOutputPath>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'purview-openapi-xml')) + + + <_PurviewOpenAPIXmlCommentCandidate Include="@(AdditionalFiles)" Condition="'%(Extension)' == '.xml'" /> + + + + + + + + + + + + <_PurviewOpenAPIXmlCommentReferenceDoc Include="@(AdditionalFiles)" Condition="'%(Extension)' == '.xml'" /> + + + + + + + diff --git a/src/src/XmlComments/XmlCommentDeduplicator.cs b/src/src/XmlComments/XmlCommentDeduplicator.cs new file mode 100644 index 0000000..86fdb0f --- /dev/null +++ b/src/src/XmlComments/XmlCommentDeduplicator.cs @@ -0,0 +1,150 @@ +namespace Purview.OpenAPI.XmlComments; + +/// +/// Why a documentation ID was removed. +/// +enum RemovalReason +{ + /// The ID occurs more than once across the documentation files. + Duplicate, + + /// The ID was listed in PurviewOpenAPIXmlCommentExclude. + Excluded, +} + +/// +/// A documentation ID that was removed, and the files it was removed from. +/// +/// The normalised documentation ID. +/// The names of the files it was removed from, in input order, once per occurrence. +/// Why it was removed. +sealed class RemovedMember(string id, IReadOnlyList sources, RemovalReason reason) +{ + /// + /// Gets the normalised documentation ID. + /// + public string Id { get; } = id; + + /// + /// Gets the names of the files the ID was removed from, in input order, once per occurrence. + /// + public IReadOnlyList Sources { get; } = sources; + + /// + /// Gets why the ID was removed. + /// + public RemovalReason Reason { get; } = reason; +} + +/// +/// Removes the documentation IDs that would collide in the OpenAPI XML-comment generator's cache. +/// +/// +/// The generator adds every <member> of every documentation file in AdditionalFiles, and every +/// public documented symbol of the project's own compilation, to one dictionary with Add, so any ID that +/// arrives twice throws when the cache is first used. A type that a source generator or polyfill injects into every +/// assembly arrives once per referenced assembly, and once more from the project itself. +/// +static class XmlCommentDeduplicator +{ + /// + /// Removes, in place, every occurrence of each ID that occurs more than once across , and + /// every occurrence of each ID in . + /// + /// The documentation files, in the order the generator receives them. + /// IDs to remove even when they occur once. They are normalised before matching. + /// The removed IDs, ordered ordinally. + /// + /// + /// Every occurrence is removed, not all but the first. When the project's own compilation also declares the type + /// (the usual case for generator-injected types) it contributes the only remaining copy; keeping a reference's copy + /// as well would still collide with it. + /// + /// + /// A duplicated ID is ambient code (an injected attribute, a polyfill), not API surface, so removing it loses no + /// endpoint or schema description. + /// + /// + public static IReadOnlyList Deduplicate( + IReadOnlyList files, + IEnumerable excludedIds + ) + { + HashSet excluded = new(excludedIds.Select(DocumentationId.Normalize), StringComparer.Ordinal); + Dictionary> sources = new(StringComparer.Ordinal); + + foreach (var file in files) + { + foreach (var member in file.Members) + { + var id = XmlDocumentation.IdOf(member); + if (!sources.TryGetValue(id, out var list)) + { + sources[id] = list = []; + } + + list.Add(file.Name); + } + } + + var removed = sources + .Where(source => source.Value.Count > 1 || excluded.Contains(source.Key)) + .OrderBy(source => source.Key, StringComparer.Ordinal) + .Select(source => new RemovedMember( + source.Key, + source.Value, + excluded.Contains(source.Key) ? RemovalReason.Excluded : RemovalReason.Duplicate + )) + .ToList(); + + if (removed.Count == 0) + { + return removed; + } + + HashSet removedIds = new(removed.Select(member => member.Id), StringComparer.Ordinal); + foreach (var file in files) + { + // Materialised first: removing an element while enumerating its siblings ends the enumeration early. + foreach ( + var member in file.Members.Where(member => removedIds.Contains(XmlDocumentation.IdOf(member))).ToList() + ) + { + member.Remove(); + } + } + + return removed; + } + + /// + /// Finds the IDs documented both in and in . + /// + /// The project's own documentation file. + /// The documentation files the generator reads, after deduplication. + /// Each overlapping ID with the reference file it also appears in, ordered by file then ID. + /// + /// The project's own compilation is only known once it compiles, so cannot see it. An + /// overlap only collides when the project's copy is public, because the generator reads public symbols from its + /// own compilation, so this can over-report. + /// + public static IReadOnlyList> FindProjectOverlap( + XmlDocumentation project, + IReadOnlyList references + ) + { + HashSet own = new(project.Members.Select(XmlDocumentation.IdOf), StringComparer.Ordinal); + + return + [ + .. references.SelectMany(reference => + reference + .Members.Select(XmlDocumentation.IdOf) + .Where(own.Contains) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .Select(id => new KeyValuePair(id, reference.Name)) + ), + ]; + } +} diff --git a/src/src/XmlComments/XmlComments.csproj b/src/src/XmlComments/XmlComments.csproj new file mode 100644 index 0000000..44462ac --- /dev/null +++ b/src/src/XmlComments/XmlComments.csproj @@ -0,0 +1,40 @@ + + + true + Purview OpenAPI XML Comments + Stops the Microsoft.AspNetCore.OpenApi XML-comment source generator from failing with "An item with the same key has already been added" when the same documentation ID comes from more than one assembly, by removing duplicated members from the referenced XML documentation it reads. Keeps every other description. + openapi;aspnetcore;xml-comments;xml-documentation;msbuild;source-generator;purview; + + + + netstandard2.0 + + + tasks + true + + true + $(NoWarn);NU5100;NU5128 + + + + + + + + diff --git a/src/src/XmlComments/XmlDocumentation.cs b/src/src/XmlComments/XmlDocumentation.cs new file mode 100644 index 0000000..efd7121 --- /dev/null +++ b/src/src/XmlComments/XmlDocumentation.cs @@ -0,0 +1,87 @@ +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace Purview.OpenAPI.XmlComments; + +/// +/// A compiler-produced XML documentation file: a <doc> root holding <members>. +/// +/// A display name for the file, used in log messages. +/// The loaded document. It must be a documentation file (see ). +sealed class XmlDocumentation(string name, XDocument document) +{ + /// + /// Gets the display name for the file, used in log messages. + /// + public string Name { get; } = name; + + /// + /// Gets the loaded document. + /// + public XDocument Document { get; } = document; + + /// + /// Gets the file's <member> elements that carry a name. + /// + public IEnumerable Members => + Document + .Root.Element("members") + .Elements("member") + .Where(member => !string.IsNullOrEmpty((string?)member.Attribute("name"))); + + /// + /// Gets the documentation ID of , normalised as the OpenAPI generator keys it. + /// + /// One of . + /// The normalised documentation ID. + public static string IdOf(XElement member) => DocumentationId.Normalize((string)member.Attribute("name")); + + /// + /// Determines whether is an XML documentation file. + /// + /// The document to test. + /// for a <doc> root holding <members>. + /// + /// AdditionalFiles can hold any XML, so everything else is left for the generator to read untouched. + /// + public static bool IsDocumentation(XDocument document) => + document.Root is { Name.LocalName: "doc" } root && root.Element("members") is not null; + + /// + /// Loads as an XML documentation file. + /// + /// The file to load. + /// + /// The documentation file when exists, parses and is a documentation file; otherwise + /// , so an unreadable file is passed through rather than failing the build. + /// + public static XmlDocumentation? Load(string path) + { + try + { + var document = XDocument.Load(path, LoadOptions.PreserveWhitespace); + + return IsDocumentation(document) ? new(Path.GetFileNameWithoutExtension(path), document) : null; + } + catch (Exception ex) when (ex is IOException or XmlException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Renders the document as the text to write to disk. + /// + /// The XML declaration (when present) and the root element, without reformatting. + public string ToFileText() + { + StringBuilder text = new(); + if (Document.Declaration is not null) + { + text.Append(Document.Declaration).Append('\n'); + } + + return text.Append(Document.Root.ToString(SaveOptions.DisableFormatting)).Append('\n').ToString(); + } +} diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj new file mode 100644 index 0000000..ceb537a --- /dev/null +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + + net10.0 + false + + + + + + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs new file mode 100644 index 0000000..ca65bc3 --- /dev/null +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -0,0 +1,65 @@ +using Purview.Skills.Testing; + +namespace Purview.OpenAPI.Repository; + +/// +/// Runs the Agent Skills conformance rule set against this repository's authored skills in skills/. +/// +/// +/// +/// [InheritsTests] is load-bearing. TUnit does not walk base types when it discovers tests, so without it +/// this class compiles, the run is green, and nothing is validated. +/// +/// +/// There is no RepositoryRoot override: the default is the SDK's repository-root marker walk. +/// is what catches a walk that landed elsewhere - every inherited +/// rule passes vacuously against a catalogue that was never found. +/// +/// +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests +{ + [Test] + public async Task SkillsFolder_ResolvesToThisRepository() + { + // Arrange + var catalogue = Evaluator.Catalogue; + + // Act + var agentsFile = Path.Combine(catalogue.RepositoryRoot, "AGENTS.md"); + + // Assert + await Assert + .That(catalogue.FolderExists) + .IsTrue() + .Because($"the marker walk resolved '{catalogue.RepositoryRoot}', whose skills folder does not exist"); + await Assert + .That(File.Exists(agentsFile)) + .IsTrue() + .Because($"'{catalogue.RepositoryRoot}' does not look like this repository"); + await Assert.That(catalogue.Skills).IsNotEmpty(); + } + + [Test] + public async Task EverySkill_IsNamedForThisProduct() + { + // Arrange + // Skills install into one flat directory per agent, shared with every skill a developer has installed from + // anywhere. An unprefixed name collides with somebody else's, and the loser is whichever installed second. + var skills = Evaluator.Catalogue.Skills; + + // Act + var unprefixed = skills + .Select(skill => skill.FolderName) + .Where(name => !name.StartsWith("purview-openapi-xmlcomments", StringComparison.Ordinal)) + .ToArray(); + + // Assert + await Assert + .That(unprefixed) + .IsEmpty() + .Because( + "every skill this repository publishes must carry the product prefix: " + string.Join(", ", unprefixed) + ); + } +} diff --git a/src/tests/XmlComments.IntegrationTests/FindDuplicateDocumentationIdsScriptTests.cs b/src/tests/XmlComments.IntegrationTests/FindDuplicateDocumentationIdsScriptTests.cs new file mode 100644 index 0000000..edf77f3 --- /dev/null +++ b/src/tests/XmlComments.IntegrationTests/FindDuplicateDocumentationIdsScriptTests.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using Purview.OpenAPI.XmlComments.Harness; +using Purview.OpenAPI.XmlComments.Infra; + +namespace Purview.OpenAPI.XmlComments; + +/// +/// Runs the purview-openapi-xmlcomments-duplicates skill's diagnostic script against real consumers, so the +/// skill's instructions stay true to what the script reports. +/// +public sealed class FindDuplicateDocumentationIdsScriptTests +{ + const int Timeout = 10 * 60 * 1000; + + [Test] + [Timeout(Timeout)] + public async Task Script_GivenDuplicateBetweenReferencesWithDeduplicationOff_ReportsItUnhandled( + CancellationToken cancellationToken + ) + { + // Arrange + var solution = await CreateBuiltSolutionAsync(new(Deduplication: false), cancellationToken); + + // Act + var diagnosis = await solution.DiagnoseAsync(cancellationToken); + + // Assert + await Assert.That(diagnosis.ExitCode).IsEqualTo(1).Because(diagnosis.Output); + using var report = Parse(diagnosis); + var finding = SingleFinding(report); + await Assert.That(finding.GetProperty("Kind").GetString()).IsEqualTo("BetweenReferences"); + await Assert.That(finding.GetProperty("Handled").GetBoolean()).IsFalse(); + await Assert + .That(finding.GetProperty("Sources").EnumerateArray().Select(source => source.GetString() ?? string.Empty)) + .IsEquivalentTo(["Lib.A.xml", "Lib.B.xml"]); + var duplicateKey = report + .RootElement.GetProperty("Cache") + .GetProperty("DuplicateKeys") + .EnumerateArray() + .Single(); + await Assert.That(duplicateKey.GetProperty("Id").GetString()).IsEqualTo(ConsumerSolution.InjectedTypeId); + await Assert.That(duplicateKey.GetProperty("Count").GetInt32()).IsEqualTo(3); + } + + [Test] + [Timeout(Timeout)] + public async Task Script_GivenTypeInOneReferenceAndTheProject_RecommendsTheExclude( + CancellationToken cancellationToken + ) + { + // Arrange + var solution = await CreateBuiltSolutionAsync(new(LibraryCount: 1), cancellationToken); + + // Act + var diagnosis = await solution.DiagnoseAsync(cancellationToken); + + // Assert + await Assert.That(diagnosis.ExitCode).IsEqualTo(1).Because(diagnosis.Output); + using var report = Parse(diagnosis); + var finding = SingleFinding(report); + await Assert.That(finding.GetProperty("Kind").GetString()).IsEqualTo("ReferenceAndProject"); + await Assert.That(finding.GetProperty("Handled").GetBoolean()).IsFalse(); + await Assert + .That(Recommendations(report)) + .Contains(recommendation => + recommendation.Contains( + $"", + StringComparison.Ordinal + ) + ); + } + + [Test] + [Timeout(Timeout)] + public async Task Script_GivenFixedConsumer_ReportsNothingWouldThrow(CancellationToken cancellationToken) + { + // Arrange + var solution = await CreateBuiltSolutionAsync(new(), cancellationToken); + + // Act + var diagnosis = await solution.DiagnoseAsync(cancellationToken); + + // Assert + await Assert.That(diagnosis.ExitCode).IsEqualTo(0).Because(diagnosis.Output); + using var report = Parse(diagnosis); + await Assert.That(report.RootElement.GetProperty("GeneratorPresent").GetBoolean()).IsTrue(); + await Assert.That(report.RootElement.GetProperty("WouldThrow").GetBoolean()).IsFalse(); + await Assert.That(SingleFinding(report).GetProperty("Handled").GetBoolean()).IsTrue(); + await Assert + .That(report.RootElement.GetProperty("Cache").GetProperty("DuplicateKeys").GetArrayLength()) + .IsEqualTo(0); + } + + [Test] + [Timeout(Timeout)] + public async Task Script_GivenProjectWithoutOpenAPI_ReportsNoGenerator(CancellationToken cancellationToken) + { + // Arrange + var solution = await CreateBuiltSolutionAsync(new(ReferencesOpenAPI: false), cancellationToken); + + // Act + var diagnosis = await solution.DiagnoseAsync(cancellationToken); + + // Assert + await Assert.That(diagnosis.ExitCode).IsEqualTo(0).Because(diagnosis.Output); + using var report = Parse(diagnosis); + await Assert.That(report.RootElement.GetProperty("GeneratorPresent").GetBoolean()).IsFalse(); + await Assert.That(report.RootElement.GetProperty("Findings").GetArrayLength()).IsEqualTo(0); + } + + static async Task CreateBuiltSolutionAsync( + ConsumerOptions options, + CancellationToken cancellationToken + ) + { + var solution = await ConsumerSolution.CreateAsync(options, cancellationToken); + var build = await solution.BuildAsync(cancellationToken); + if (build.ExitCode != 0) + { + throw new InvalidOperationException($"The consumer did not build:\n{build.Output}"); + } + + return solution; + } + + static JsonDocument Parse(ProcessResult diagnosis) => JsonDocument.Parse(diagnosis.StandardOutput); + + static JsonElement SingleFinding(JsonDocument report) => + report.RootElement.GetProperty("Findings").EnumerateArray().Single(); + + static List Recommendations(JsonDocument report) => + [ + .. report + .RootElement.GetProperty("Recommendations") + .EnumerateArray() + .Select(item => item.GetString() ?? string.Empty), + ]; +} diff --git a/src/tests/XmlComments.IntegrationTests/Harness/ConsumerSolution.cs b/src/tests/XmlComments.IntegrationTests/Harness/ConsumerSolution.cs new file mode 100644 index 0000000..ddce5c1 --- /dev/null +++ b/src/tests/XmlComments.IntegrationTests/Harness/ConsumerSolution.cs @@ -0,0 +1,289 @@ +using System.Text.RegularExpressions; +using Purview.OpenAPI.XmlComments.Infra; + +namespace Purview.OpenAPI.XmlComments.Harness; + +/// +/// What a is made of. +/// +/// The consumer's target framework, net10.0 or net11.0. +/// How many class libraries the API references. Each is Lib.A, Lib.B, ... +/// The value of PurviewOpenAPIXmlCommentDeduplication; leaves the default. +/// The PurviewOpenAPIXmlCommentExclude items the API declares. +/// Whether the API references Microsoft.AspNetCore.OpenApi at all. +sealed record ConsumerOptions( + string TargetFramework = "net10.0", + int LibraryCount = 2, + bool? Deduplication = null, + IReadOnlyList? ExcludedIds = null, + bool ReferencesOpenAPI = true +); + +/// +/// A throwaway consumer solution: an API and class libraries, every one of +/// which compiles - a documented public attribute standing in for a type a source +/// generator or polyfill injects into every assembly. +/// +/// +/// It is written to its own temporary directory with its own Directory.Build.props, +/// Directory.Build.targets and Directory.Packages.props, so nothing from this repository or the machine +/// leaks in. The API imports the package's props and targets from source, and loads a private copy of the task +/// assembly so a lingering MSBuild node cannot lock the test's own output. +/// +sealed partial class ConsumerSolution +{ + /// + /// The documentation ID of the attribute compiled into every consumer assembly. + /// + public const string InjectedTypeId = "T:Ambient.InjectedAttribute"; + + ConsumerSolution(string root, ConsumerOptions options) + { + Root = root; + Options = options; + } + + /// + /// Gets the solution's root directory. + /// + public string Root { get; } + + /// + /// Gets what the solution is made of. + /// + public ConsumerOptions Options { get; } + + /// + /// Gets the API project's directory. + /// + public string APIDirectory => Path.Combine(Root, "Api"); + + /// + /// Gets the API's intermediate output directory. + /// + public string APIIntermediateDirectory => Path.Combine(APIDirectory, "obj", "Debug", Options.TargetFramework); + + /// + /// Gets the names of the libraries the API references, A, B, ... + /// + public IReadOnlyList Libraries => + [.. Enumerable.Range(0, Options.LibraryCount).Select(i => ((char)('A' + i)).ToString())]; + + /// + /// Writes a new consumer solution to a new temporary directory. + /// + public static async Task CreateAsync(ConsumerOptions options, CancellationToken cancellationToken) + { + var root = Directory.CreateTempSubdirectory("purview-openapi-xmlcomments-it-").FullName; + ConsumerSolution solution = new(root, options); + await solution.WriteAsync(cancellationToken); + + return solution; + } + + /// + /// Builds the API, and with it every library. + /// + public Task BuildAsync(CancellationToken cancellationToken) => + ProcessRunner.RunDotNetAsync("build Api/Api.csproj -nodeReuse:false", Root, cancellationToken); + + /// + /// Runs the built API, which serves /openapi/v1.json to itself and exits 0 only on a success status. + /// + public Task RunAPIAsync(CancellationToken cancellationToken) => + ProcessRunner.RunDotNetAsync($"bin/Debug/{Options.TargetFramework}/Api.dll", APIDirectory, cancellationToken); + + /// + /// Runs the skill's find-duplicate-documentation-ids.ps1 against the API with -Json. + /// + public Task DiagnoseAsync(CancellationToken cancellationToken) => + ProcessRunner.RunAsync( + "pwsh", + $"-NoProfile -NonInteractive -File \"{ConsumerConstants.SkillScript}\" -Project \"{APIDirectory}\" -Json", + Root, + cancellationToken + ); + + /// + /// Counts the entries the generated XML-comment cache adds for . + /// + public async Task CountCacheEntriesAsync(string id, CancellationToken cancellationToken) + { + var generated = Directory + .EnumerateFiles( + APIIntermediateDirectory, + "OpenApiXmlCommentSupport.generated.cs", + SearchOption.AllDirectories + ) + .Single(); + var source = await File.ReadAllTextAsync(generated, cancellationToken); + + return Regex.Count(source, $@"cache\.Add\(@""{Regex.Escape(id)}"""); + } + + async Task WriteAsync(CancellationToken cancellationToken) + { + await WriteFileAsync( + "Directory.Build.props", + $""" + + + {Options.TargetFramework} + enable + enable + true + $(NoWarn);CS1591 + true + + + """, + cancellationToken + ); + await WriteFileAsync("Directory.Build.targets", "", cancellationToken); + await WriteFileAsync( + "Directory.Packages.props", + """ + + + false + + + """, + cancellationToken + ); + await WriteFileAsync( + "Ambient.cs", + """ + namespace Ambient; + + /// An attribute a source generator would inject into every assembly. + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class InjectedAttribute : System.Attribute { } + """, + cancellationToken + ); + + foreach (var library in Libraries) + { + await WriteFileAsync( + $"Lib.{library}/Lib.{library}.csproj", + """ + + + + + + """, + cancellationToken + ); + await WriteFileAsync( + $"Lib.{library}/Widget{library}.cs", + $$""" + namespace Lib.{{library}}; + + /// Widget from library {{library}}. + public sealed class Widget{{library}} + { + /// The name of the widget from library {{library}}. + public string Name { get; set; } = "{{library}}"; + } + """, + cancellationToken + ); + } + + await WriteAPIAsync(cancellationToken); + } + + async Task WriteAPIAsync(CancellationToken cancellationToken) + { + // A private copy: MSBuild keeps a task assembly loaded for as long as its node lives. Located through a type with + // no MSBuild base class, because loading a task type needs the MSBuild assemblies this process does not have. + var builtTaskAssembly = typeof(XmlCommentDeduplicator).Assembly.Location; + var taskAssembly = Path.Combine(Root, "tasks", Path.GetFileName(builtTaskAssembly)); + Directory.CreateDirectory(Path.GetDirectoryName(taskAssembly)!); + File.Copy(builtTaskAssembly, taskAssembly); + + var openAPIVersion = + Options.TargetFramework == "net11.0" + ? ConsumerConstants.OpenAPINet11Version + : ConsumerConstants.OpenAPINet10Version; + var deduplication = Options.Deduplication is { } enabled + ? $"{(enabled ? "true" : "false")}" + : string.Empty; + var openAPIReference = Options.ReferencesOpenAPI + ? $"""""" + : string.Empty; + var libraryReferences = string.Concat( + Libraries.Select(library => $"""""") + ); + var excludes = string.Concat( + (Options.ExcludedIds ?? []).Select(id => $"""""") + ); + + await WriteFileAsync( + "Api/Api.csproj", + $""" + + + {taskAssembly} + {deduplication} + + + + {openAPIReference} + {libraryReferences} + {excludes} + + + + + """, + cancellationToken + ); + + var endpoints = string.Concat( + Libraries.Select(library => + $""" + + app.MapGet("/{library}", () => new Lib.{library}.Widget{library}()); + """ + ) + ); + var program = Options.ReferencesOpenAPI + ? $$""" + using Microsoft.AspNetCore.Hosting.Server; + using Microsoft.AspNetCore.Hosting.Server.Features; + + var builder = WebApplication.CreateBuilder(args); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddOpenApi(); + + var app = builder.Build(); + app.MapOpenApi();{{endpoints}} + + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.First(); + using var client = new HttpClient { BaseAddress = new Uri(address) }; + using var response = await client.GetAsync("/openapi/v1.json"); + Console.WriteLine($"STATUS {(int)response.StatusCode}"); + Console.WriteLine(await response.Content.ReadAsStringAsync()); + await app.StopAsync(); + + return response.IsSuccessStatusCode ? 0 : 1; + """ + : """ + var app = WebApplication.CreateBuilder(args).Build(); + app.MapGet("/", () => "ok"); + return 0; + """; + await WriteFileAsync("Api/Program.cs", program, cancellationToken); + } + + async Task WriteFileAsync(string relativePath, string content, CancellationToken cancellationToken) + { + var path = Path.Combine(Root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, content, cancellationToken); + } +} diff --git a/src/tests/XmlComments.IntegrationTests/Infra/ProcessRunner.cs b/src/tests/XmlComments.IntegrationTests/Infra/ProcessRunner.cs new file mode 100644 index 0000000..cee338d --- /dev/null +++ b/src/tests/XmlComments.IntegrationTests/Infra/ProcessRunner.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; + +namespace Purview.OpenAPI.XmlComments.Infra; + +/// +/// The one place a test spawns a process. Every consumer is built and run through here so that no test depends on +/// the environment of the test host. +/// +/// +/// A test run under dotnet test inherits MSBuild variables (MSBuildExtensionsPath, +/// MSBUILD_EXE_PATH, MSBuildSDKsPath) that point a child dotnet build at the host's MSBuild, and +/// CI variables that change SDK behaviour. Both are removed. +/// +static class ProcessRunner +{ + /// + /// Runs dotnet in . + /// + public static Task RunDotNetAsync( + string arguments, + string workingDirectory, + CancellationToken cancellationToken + ) => RunAsync("dotnet", arguments, workingDirectory, cancellationToken); + + /// + /// Runs with in . + /// + public static async Task RunAsync( + string fileName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken + ) + { + using Process process = new() + { + StartInfo = new(fileName, arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + + var environment = process.StartInfo.Environment; + foreach (var name in environment.Keys.ToList()) + { + if ( + name.StartsWith("MSBUILD", StringComparison.OrdinalIgnoreCase) + || CiEnvironmentVariables.Contains(name, StringComparer.OrdinalIgnoreCase) + ) + { + environment.Remove(name); + } + } + + environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + environment["DOTNET_NOLOGO"] = "1"; + environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; + + process.Start(); + var output = process.StandardOutput.ReadToEndAsync(cancellationToken); + var error = process.StandardError.ReadToEndAsync(cancellationToken); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw; + } + + return new(process.ExitCode, await output, await error); + } + + static readonly string[] CiEnvironmentVariables = + [ + "CI", + "GITHUB_ACTIONS", + "GITHUB_WORKSPACE", + "TF_BUILD", + "BUILD_SOURCESDIRECTORY", + ]; +} + +/// +/// The outcome of a process run by . +/// +/// The process's exit code. +/// Its standard output. +/// Its standard error. +sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError) +{ + /// + /// Gets the standard output followed by the standard error, for assertions on everything a process printed. + /// + public string Output => StandardOutput + StandardError; +} diff --git a/src/tests/XmlComments.IntegrationTests/XmlComments.IntegrationTests.csproj b/src/tests/XmlComments.IntegrationTests/XmlComments.IntegrationTests.csproj new file mode 100644 index 0000000..154f4c6 --- /dev/null +++ b/src/tests/XmlComments.IntegrationTests/XmlComments.IntegrationTests.csproj @@ -0,0 +1,45 @@ + + + + + net10.0 + + + + + + <_SdkDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../src/XmlComments/Sdk').Replace('\', '/')) + <_SkillScript>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../skills/purview-openapi-xmlcomments-duplicates/scripts/find-duplicate-documentation-ids.ps1').Replace('\', '/')) + <_ConsumerConstantsFile>$(IntermediateOutputPath)Generated/ConsumerConstants.cs + + + + + + + + diff --git a/src/tests/XmlComments.IntegrationTests/XmlCommentsTargetsTests.cs b/src/tests/XmlComments.IntegrationTests/XmlCommentsTargetsTests.cs new file mode 100644 index 0000000..321eaf3 --- /dev/null +++ b/src/tests/XmlComments.IntegrationTests/XmlCommentsTargetsTests.cs @@ -0,0 +1,145 @@ +using Purview.OpenAPI.XmlComments.Harness; + +namespace Purview.OpenAPI.XmlComments; + +/// +/// Builds and runs real consumers of Purview.OpenAPI.XmlComments.props/.targets against the real OpenAPI +/// XML-comment generator. +/// +/// +/// Every consumer compiles the same documented public attribute into the API and each library, which is what a source +/// generator's post-initialization output or a polyfill does, and what makes the generated cache throw. +/// +public sealed class XmlCommentsTargetsTests +{ + const int BuildTimeout = 10 * 60 * 1000; + + [Test] + [Timeout(BuildTimeout)] + public async Task Build_GivenDeduplicationDisabled_ReproducesTheDuplicateKeyFailure( + CancellationToken cancellationToken + ) + { + // Arrange + var solution = await ConsumerSolution.CreateAsync(new(Deduplication: false), cancellationToken); + + // Act + var build = await solution.BuildAsync(cancellationToken); + var run = await solution.RunAPIAsync(cancellationToken); + + // Assert + await Assert.That(build.ExitCode).IsEqualTo(0).Because(build.Output); + await Assert + .That(await solution.CountCacheEntriesAsync(ConsumerSolution.InjectedTypeId, cancellationToken)) + .IsEqualTo(3); + await Assert.That(run.ExitCode).IsNotEqualTo(0); + await Assert.That(run.Output).Contains("An item with the same key has already been added"); + } + + [Test] + [Timeout(BuildTimeout)] + [Arguments("net10.0")] + [Arguments("net11.0")] + public async Task Build_GivenTypeInEveryAssembly_ServesTheDocumentWithEveryDescription( + string targetFramework, + CancellationToken cancellationToken + ) + { + // Arrange + var solution = await ConsumerSolution.CreateAsync(new(TargetFramework: targetFramework), cancellationToken); + + // Act + var build = await solution.BuildAsync(cancellationToken); + var run = await solution.RunAPIAsync(cancellationToken); + + // Assert + await Assert.That(build.ExitCode).IsEqualTo(0).Because(build.Output); + await Assert.That(build.Output).DoesNotContain(CheckOpenAPIXmlCommentOverlap.OverlapWarningCode); + await Assert + .That(await solution.CountCacheEntriesAsync(ConsumerSolution.InjectedTypeId, cancellationToken)) + .IsEqualTo(1); + await Assert.That(run.ExitCode).IsEqualTo(0).Because(run.Output); + await Assert.That(run.Output).Contains("Widget from library A."); + await Assert.That(run.Output).Contains("Widget from library B."); + } + + [Test] + [Timeout(BuildTimeout)] + public async Task Build_GivenTypeInOneReferenceAndTheProject_WarnsWithTheExcludeToAdd( + CancellationToken cancellationToken + ) + { + // Arrange + var solution = await ConsumerSolution.CreateAsync(new(LibraryCount: 1), cancellationToken); + + // Act + var build = await solution.BuildAsync(cancellationToken); + + // Assert + await Assert.That(build.ExitCode).IsEqualTo(0).Because(build.Output); + await Assert.That(build.Output).Contains($"warning {CheckOpenAPIXmlCommentOverlap.OverlapWarningCode}"); + await Assert + .That(build.Output) + .Contains($""); + } + + [Test] + [Timeout(BuildTimeout)] + public async Task Build_GivenExcludedId_ServesTheDocumentWithoutWarning(CancellationToken cancellationToken) + { + // Arrange + var solution = await ConsumerSolution.CreateAsync( + new(LibraryCount: 1, ExcludedIds: [ConsumerSolution.InjectedTypeId]), + cancellationToken + ); + + // Act + var build = await solution.BuildAsync(cancellationToken); + var run = await solution.RunAPIAsync(cancellationToken); + + // Assert + await Assert.That(build.ExitCode).IsEqualTo(0).Because(build.Output); + await Assert.That(build.Output).DoesNotContain(CheckOpenAPIXmlCommentOverlap.OverlapWarningCode); + await Assert + .That(await solution.CountCacheEntriesAsync(ConsumerSolution.InjectedTypeId, cancellationToken)) + .IsEqualTo(1); + await Assert.That(run.ExitCode).IsEqualTo(0).Because(run.Output); + } + + [Test] + [Timeout(BuildTimeout)] + public async Task Build_GivenUnchangedInputs_DoesNotRecompileTheAPI(CancellationToken cancellationToken) + { + // Arrange + var solution = await ConsumerSolution.CreateAsync(new(), cancellationToken); + var first = await solution.BuildAsync(cancellationToken); + var assembly = Path.Combine(solution.APIIntermediateDirectory, "Api.dll"); + var compiled = File.GetLastWriteTimeUtc(assembly); + + // Act + var second = await solution.BuildAsync(cancellationToken); + + // Assert + await Assert.That(first.ExitCode).IsEqualTo(0).Because(first.Output); + await Assert.That(second.ExitCode).IsEqualTo(0).Because(second.Output); + await Assert.That(File.GetLastWriteTimeUtc(assembly)).IsEqualTo(compiled); + } + + [Test] + [Timeout(BuildTimeout)] + public async Task Build_GivenProjectWithoutOpenAPI_LeavesItUntouched(CancellationToken cancellationToken) + { + // Arrange + var solution = await ConsumerSolution.CreateAsync(new(ReferencesOpenAPI: false), cancellationToken); + + // Act + var build = await solution.BuildAsync(cancellationToken); + + // Assert + await Assert.That(build.ExitCode).IsEqualTo(0).Because(build.Output); + await Assert + .That(Directory.Exists(Path.Combine(solution.APIIntermediateDirectory, "purview-openapi-xml"))) + .IsFalse(); + await Assert.That(build.Output).DoesNotContain(CheckOpenAPIXmlCommentOverlap.OverlapWarningCode); + } +} diff --git a/src/tests/XmlComments.UnitTests/CheckOpenAPIXmlCommentOverlapTests.cs b/src/tests/XmlComments.UnitTests/CheckOpenAPIXmlCommentOverlapTests.cs new file mode 100644 index 0000000..4f9beb3 --- /dev/null +++ b/src/tests/XmlComments.UnitTests/CheckOpenAPIXmlCommentOverlapTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Build.Framework; +using TaskItem = Microsoft.Build.Utilities.TaskItem; + +namespace Purview.OpenAPI.XmlComments; + +public sealed class CheckOpenAPIXmlCommentOverlapTests +{ + [Test] + public async Task Execute_GivenIdInProjectAndReference_LogsOverlapWarning(CancellationToken cancellationToken) + { + // Arrange + var shared = TestBuilder.TypeId(); + var project = await TestFiles.WriteAsync("Api.xml", TestBuilder.DocumentationText([shared]), cancellationToken); + var reference = await TestFiles.WriteAsync( + "Lib.A.xml", + TestBuilder.DocumentationText([shared]), + cancellationToken + ); + var engine = Mock.Of(); + var task = CreateTask(engine.Object, project, [reference]); + + // Act + var succeeded = task.Execute(); + + // Assert + await Assert.That(succeeded).IsTrue(); + engine + .LogWarningEvent( + Is(warning => + warning is { Code: CheckOpenAPIXmlCommentOverlap.OverlapWarningCode, Message: { } message } + && message.Contains(shared, StringComparison.Ordinal) + && message.Contains("Lib.A.xml", StringComparison.Ordinal) + ) + ) + .WasCalled(Times.Once); + } + + [Test] + public async Task Execute_GivenNoSharedId_LogsNoWarning(CancellationToken cancellationToken) + { + // Arrange + var project = await TestFiles.WriteAsync( + "Api.xml", + TestBuilder.DocumentationText([TestBuilder.TypeId()]), + cancellationToken + ); + var reference = await TestFiles.WriteAsync( + "Lib.A.xml", + TestBuilder.DocumentationText([TestBuilder.TypeId()]), + cancellationToken + ); + var engine = Mock.Of(); + var task = CreateTask(engine.Object, project, [reference]); + + // Act + var succeeded = task.Execute(); + + // Assert + await Assert.That(succeeded).IsTrue(); + engine.LogWarningEvent(Any()).WasNeverCalled(); + } + + [Test] + public async Task Execute_GivenMissingProjectDocumentation_SucceedsWithoutWarning( + CancellationToken cancellationToken + ) + { + // Arrange + var reference = await TestFiles.WriteAsync( + "Lib.A.xml", + TestBuilder.DocumentationText([TestBuilder.TypeId()]), + cancellationToken + ); + var engine = Mock.Of(); + var task = CreateTask(engine.Object, Path.Combine(TestFiles.CreateDirectory(), "Api.xml"), [reference]); + + // Act + var succeeded = task.Execute(); + + // Assert + await Assert.That(succeeded).IsTrue(); + engine.LogWarningEvent(Any()).WasNeverCalled(); + } + + static CheckOpenAPIXmlCommentOverlap CreateTask( + IBuildEngine engine, + string project, + IEnumerable references + ) => + new() + { + BuildEngine = engine, + ProjectDocumentation = project, + ReferenceDocumentation = [.. references.Select(reference => (ITaskItem)new TaskItem(reference))], + }; +} diff --git a/src/tests/XmlComments.UnitTests/DeduplicateOpenAPIXmlCommentsTests.cs b/src/tests/XmlComments.UnitTests/DeduplicateOpenAPIXmlCommentsTests.cs new file mode 100644 index 0000000..22d9f2a --- /dev/null +++ b/src/tests/XmlComments.UnitTests/DeduplicateOpenAPIXmlCommentsTests.cs @@ -0,0 +1,168 @@ +using Microsoft.Build.Framework; +using TaskItem = Microsoft.Build.Utilities.TaskItem; + +namespace Purview.OpenAPI.XmlComments; + +public sealed class DeduplicateOpenAPIXmlCommentsTests +{ + [Test] + public async Task Execute_GivenIdInTwoFiles_WritesFilteredCopiesOfBoth(CancellationToken cancellationToken) + { + // Arrange + var shared = TestBuilder.TypeId(); + var first = await TestFiles.WriteAsync( + "Lib.A.xml", + TestBuilder.DocumentationText([shared, TestBuilder.TypeId()]), + cancellationToken + ); + var second = await TestFiles.WriteAsync( + "Lib.B.xml", + TestBuilder.DocumentationText([shared]), + cancellationToken + ); + var task = CreateTask([first, second]); + + // Act + var succeeded = task.Execute(); + + // Assert + await Assert.That(succeeded).IsTrue(); + await Assert.That(task.Replaced.Select(item => item.ItemSpec)).IsEquivalentTo([first, second]); + await Assert + .That(task.Replacements.Select(item => Path.GetFileName(item.ItemSpec))) + .IsEquivalentTo(["Lib.A.xml", "Lib.B.xml"]); + foreach (var replacement in task.Replacements) + { + await Assert.That(Path.GetDirectoryName(replacement.ItemSpec)).IsEqualTo(task.OutputDirectory); + await Assert + .That(await File.ReadAllTextAsync(replacement.ItemSpec, cancellationToken)) + .DoesNotContain(shared); + } + } + + [Test] + public async Task Execute_GivenNoCollidingIds_ReplacesNothing(CancellationToken cancellationToken) + { + // Arrange + var first = await TestFiles.WriteAsync( + "Lib.A.xml", + TestBuilder.DocumentationText([TestBuilder.TypeId()]), + cancellationToken + ); + var second = await TestFiles.WriteAsync( + "Lib.B.xml", + TestBuilder.DocumentationText([TestBuilder.TypeId()]), + cancellationToken + ); + var task = CreateTask([first, second]); + + // Act + var succeeded = task.Execute(); + + // Assert + await Assert.That(succeeded).IsTrue(); + await Assert.That(task.Replaced).IsEmpty(); + await Assert.That(task.Replacements).IsEmpty(); + } + + [Test] + public async Task Execute_GivenExcludedId_ReplacesTheOneFileThatHasIt(CancellationToken cancellationToken) + { + // Arrange + var excluded = TestBuilder.TypeId(); + var first = await TestFiles.WriteAsync( + "Lib.A.xml", + TestBuilder.DocumentationText([excluded]), + cancellationToken + ); + var second = await TestFiles.WriteAsync( + "Lib.B.xml", + TestBuilder.DocumentationText([TestBuilder.TypeId()]), + cancellationToken + ); + var task = CreateTask([first, second], exclude: [excluded]); + + // Act + task.Execute(); + + // Assert + await Assert.That(task.Replaced.Select(item => item.ItemSpec)).IsEquivalentTo([first]); + } + + [Test] + public async Task Execute_GivenXmlThatIsNotDocumentation_LeavesItInPlace(CancellationToken cancellationToken) + { + // Arrange + var shared = TestBuilder.TypeId(); + var other = await TestFiles.WriteAsync("settings.xml", "", cancellationToken); + var first = await TestFiles.WriteAsync("Lib.A.xml", TestBuilder.DocumentationText([shared]), cancellationToken); + var second = await TestFiles.WriteAsync( + "Lib.B.xml", + TestBuilder.DocumentationText([shared]), + cancellationToken + ); + var task = CreateTask([other, first, second]); + + // Act + task.Execute(); + + // Assert + await Assert.That(task.Replaced.Select(item => item.ItemSpec)).DoesNotContain(other); + } + + [Test] + public async Task Execute_GivenTwoFilesWithTheSameName_WritesDistinctCopies(CancellationToken cancellationToken) + { + // Arrange + var shared = TestBuilder.TypeId(); + var first = await TestFiles.WriteAsync("Lib.xml", TestBuilder.DocumentationText([shared]), cancellationToken); + var second = await TestFiles.WriteAsync("Lib.xml", TestBuilder.DocumentationText([shared]), cancellationToken); + var task = CreateTask([first, second]); + + // Act + task.Execute(); + + // Assert + await Assert + .That(task.Replacements.Select(item => Path.GetFileName(item.ItemSpec))) + .IsEquivalentTo(["Lib.xml", "Lib.1.xml"]); + } + + [Test] + public async Task Execute_GivenUnchangedInputs_DoesNotRewriteTheCopy(CancellationToken cancellationToken) + { + // Arrange + var shared = TestBuilder.TypeId(); + var first = await TestFiles.WriteAsync("Lib.A.xml", TestBuilder.DocumentationText([shared]), cancellationToken); + var second = await TestFiles.WriteAsync( + "Lib.B.xml", + TestBuilder.DocumentationText([shared]), + cancellationToken + ); + var outputDirectory = TestFiles.CreateDirectory(); + CreateTask([first, second], outputDirectory: outputDirectory).Execute(); + + var copy = Path.Combine(outputDirectory, "Lib.A.xml"); + DateTime stamped = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(copy, stamped); + + // Act + CreateTask([first, second], outputDirectory: outputDirectory).Execute(); + + // Assert + await Assert.That(File.GetLastWriteTimeUtc(copy)).IsEqualTo(stamped); + } + + static DeduplicateOpenAPIXmlComments CreateTask( + IEnumerable files, + IEnumerable? exclude = null, + string? outputDirectory = null + ) => + new() + { + BuildEngine = Mock.Of().Object, + Files = [.. files.Select(file => (ITaskItem)new TaskItem(file))], + Exclude = [.. (exclude ?? []).Select(id => (ITaskItem)new TaskItem(id))], + OutputDirectory = outputDirectory ?? TestFiles.CreateDirectory(), + }; +} diff --git a/src/tests/XmlComments.UnitTests/DocumentationIdTests.cs b/src/tests/XmlComments.UnitTests/DocumentationIdTests.cs new file mode 100644 index 0000000..8aea602 --- /dev/null +++ b/src/tests/XmlComments.UnitTests/DocumentationIdTests.cs @@ -0,0 +1,44 @@ +namespace Purview.OpenAPI.XmlComments; + +public sealed class DocumentationIdTests +{ + [Test] + [Arguments("T:Ns.Type")] + [Arguments("P:Ns.Type.Value")] + [Arguments("M:Ns.Type.Method(System.Int32)")] + public async Task Normalize_GivenIdWithoutReturnType_ReturnsItUnchanged(string id) + { + // Arrange + // Act + var normalized = DocumentationId.Normalize(id); + + // Assert + await Assert.That(normalized).IsEqualTo(id); + } + + [Test] + public async Task Normalize_GivenMethodWithReturnType_RemovesTheReturnType() + { + // Arrange + const string id = "M:Ns.Type.Method(System.Int32)~System.String"; + + // Act + var normalized = DocumentationId.Normalize(id); + + // Assert + await Assert.That(normalized).IsEqualTo("M:Ns.Type.Method(System.Int32)"); + } + + [Test] + [Arguments("M:Ns.Type.op_Implicit(System.Int32)~Ns.Type")] + [Arguments("M:Ns.Type.op_Explicit(Ns.Type)~System.Int32")] + public async Task Normalize_GivenConversionOperator_KeepsTheReturnType(string id) + { + // Arrange + // Act + var normalized = DocumentationId.Normalize(id); + + // Assert + await Assert.That(normalized).IsEqualTo(id); + } +} diff --git a/src/tests/XmlComments.UnitTests/TestBuilder.cs b/src/tests/XmlComments.UnitTests/TestBuilder.cs new file mode 100644 index 0000000..7436db7 --- /dev/null +++ b/src/tests/XmlComments.UnitTests/TestBuilder.cs @@ -0,0 +1,59 @@ +using System.Xml.Linq; + +namespace Purview.OpenAPI.XmlComments; + +/// +/// Builds the XML documentation content the tests work on. +/// +static class TestBuilder +{ + internal static Faker Faker { get; } = new(); + + /// + /// A type documentation ID, T:Namespace.Type. + /// + public static string TypeId(string? fullName = null) => + $"T:{fullName ?? $"{Faker.Lorem.Word()}.{Faker.Lorem.Word()}{Faker.Random.AlphaNumeric(8)}"}"; + + /// + /// A compiler-shaped XML documentation file holding one <member> per ID, each with a generated summary. + /// + public static XmlDocumentation Documentation(IEnumerable memberIds, string? name = null) => + new( + name ?? AssemblyName(), + XDocument.Parse(DocumentationText(memberIds, name), LoadOptions.PreserveWhitespace) + ); + + /// + /// The text of a compiler-shaped XML documentation file holding one <member> per ID. + /// + public static string DocumentationText(IEnumerable memberIds, string? assemblyName = null) + { + var members = string.Concat( + memberIds.Select(id => + $""" + + + {Faker.Lorem.Sentence()} + + """ + ) + ); + + return $""" + + + + {assemblyName ?? AssemblyName()} + + {members} + + + """; + } + + /// + /// An assembly name. + /// + public static string AssemblyName() => $"{Faker.Lorem.Word()}.{Faker.Lorem.Word()}"; +} diff --git a/src/tests/XmlComments.UnitTests/TestFiles.cs b/src/tests/XmlComments.UnitTests/TestFiles.cs new file mode 100644 index 0000000..385625a --- /dev/null +++ b/src/tests/XmlComments.UnitTests/TestFiles.cs @@ -0,0 +1,29 @@ +namespace Purview.OpenAPI.XmlComments; + +/// +/// Scratch files for the tests that read and write real documentation files. +/// +static class TestFiles +{ + /// + /// A new, empty directory under the system temporary folder. + /// + public static string CreateDirectory() => Directory.CreateTempSubdirectory("purview-openapi-xmlcomments-").FullName; + + /// + /// Writes to in a new directory, or in + /// when given, and returns the file's full path. + /// + public static async Task WriteAsync( + string fileName, + string content, + CancellationToken cancellationToken, + string? directory = null + ) + { + var path = Path.Combine(directory ?? CreateDirectory(), fileName); + await File.WriteAllTextAsync(path, content, cancellationToken); + + return path; + } +} diff --git a/src/tests/XmlComments.UnitTests/XmlCommentDeduplicatorTests.cs b/src/tests/XmlComments.UnitTests/XmlCommentDeduplicatorTests.cs new file mode 100644 index 0000000..44ef5a2 --- /dev/null +++ b/src/tests/XmlComments.UnitTests/XmlCommentDeduplicatorTests.cs @@ -0,0 +1,140 @@ +namespace Purview.OpenAPI.XmlComments; + +public sealed class XmlCommentDeduplicatorTests +{ + [Test] + public async Task Deduplicate_GivenIdInTwoFiles_RemovesEveryOccurrence() + { + // Arrange + var shared = TestBuilder.TypeId(); + var first = TestBuilder.Documentation([shared, TestBuilder.TypeId()]); + var second = TestBuilder.Documentation([shared, TestBuilder.TypeId()]); + + // Act + XmlCommentDeduplicator.Deduplicate([first, second], []); + + // Assert + await Assert.That(Ids(first)).DoesNotContain(shared); + await Assert.That(Ids(second)).DoesNotContain(shared); + } + + [Test] + public async Task Deduplicate_GivenIdInTwoFiles_KeepsEveryOtherMember() + { + // Arrange + var shared = TestBuilder.TypeId(); + var firstOnly = TestBuilder.TypeId(); + var secondOnly = TestBuilder.TypeId(); + var first = TestBuilder.Documentation([shared, firstOnly]); + var second = TestBuilder.Documentation([secondOnly, shared]); + + // Act + XmlCommentDeduplicator.Deduplicate([first, second], []); + + // Assert + await Assert.That(Ids(first)).IsEquivalentTo([firstOnly]); + await Assert.That(Ids(second)).IsEquivalentTo([secondOnly]); + } + + [Test] + public async Task Deduplicate_GivenUniqueIds_RemovesNothing() + { + // Arrange + var first = TestBuilder.Documentation([TestBuilder.TypeId(), TestBuilder.TypeId()]); + var second = TestBuilder.Documentation([TestBuilder.TypeId()]); + + // Act + var removed = XmlCommentDeduplicator.Deduplicate([first, second], []); + + // Assert + await Assert.That(removed).IsEmpty(); + await Assert.That(Ids(first)).Count().IsEqualTo(2); + await Assert.That(Ids(second)).Count().IsEqualTo(1); + } + + [Test] + public async Task Deduplicate_GivenExcludedIdInOneFile_RemovesItAsExcluded() + { + // Arrange + var excluded = TestBuilder.TypeId(); + var file = TestBuilder.Documentation([excluded, TestBuilder.TypeId()]); + + // Act + var removed = XmlCommentDeduplicator.Deduplicate([file], [excluded]); + + // Assert + await Assert.That(Ids(file)).DoesNotContain(excluded); + await Assert.That(removed).Count().IsEqualTo(1); + await Assert.That(removed[0].Reason).IsEqualTo(RemovalReason.Excluded); + } + + [Test] + public async Task Deduplicate_GivenIdsThatDifferOnlyInReturnType_TreatsThemAsDuplicates() + { + // Arrange + var first = TestBuilder.Documentation(["M:Ns.Type.Method~System.String"]); + var second = TestBuilder.Documentation(["M:Ns.Type.Method~System.Int32"]); + + // Act + var removed = XmlCommentDeduplicator.Deduplicate([first, second], []); + + // Assert + await Assert.That(removed).Count().IsEqualTo(1); + await Assert.That(removed[0].Id).IsEqualTo("M:Ns.Type.Method"); + await Assert.That(Ids(first)).IsEmpty(); + await Assert.That(Ids(second)).IsEmpty(); + } + + [Test] + public async Task Deduplicate_GivenSeveralDuplicates_ReportsThemOrdinallyWithEverySource() + { + // Arrange + const string later = "T:Zeta.Type"; + const string earlier = "T:Alpha.Type"; + var first = TestBuilder.Documentation([later, earlier], name: "First"); + var second = TestBuilder.Documentation([earlier, later], name: "Second"); + + // Act + var removed = XmlCommentDeduplicator.Deduplicate([first, second], []); + + // Assert + await Assert.That(removed.Select(member => member.Id)).IsEquivalentTo([earlier, later]); + await Assert.That(removed[0].Id).IsEqualTo(earlier); + await Assert.That(removed[0].Sources).IsEquivalentTo(["First", "Second"]); + await Assert.That(removed[0].Reason).IsEqualTo(RemovalReason.Duplicate); + } + + [Test] + public async Task FindProjectOverlap_GivenIdInProjectAndReference_ReturnsItWithTheReference() + { + // Arrange + var shared = TestBuilder.TypeId(); + var project = TestBuilder.Documentation([shared, TestBuilder.TypeId()]); + var reference = TestBuilder.Documentation([TestBuilder.TypeId(), shared], name: "Reference"); + + // Act + var overlap = XmlCommentDeduplicator.FindProjectOverlap(project, [reference]); + + // Assert + await Assert.That(overlap).Count().IsEqualTo(1); + await Assert.That(overlap[0].Key).IsEqualTo(shared); + await Assert.That(overlap[0].Value).IsEqualTo("Reference"); + } + + [Test] + public async Task FindProjectOverlap_GivenNoSharedId_ReturnsEmpty() + { + // Arrange + var project = TestBuilder.Documentation([TestBuilder.TypeId()]); + var reference = TestBuilder.Documentation([TestBuilder.TypeId()]); + + // Act + var overlap = XmlCommentDeduplicator.FindProjectOverlap(project, [reference]); + + // Assert + await Assert.That(overlap).IsEmpty(); + } + + static List Ids(XmlDocumentation documentation) => + [.. documentation.Members.Select(member => (string)member.Attribute("name")!)]; +} diff --git a/src/tests/XmlComments.UnitTests/XmlComments.UnitTests.csproj b/src/tests/XmlComments.UnitTests/XmlComments.UnitTests.csproj new file mode 100644 index 0000000..e0ab09a --- /dev/null +++ b/src/tests/XmlComments.UnitTests/XmlComments.UnitTests.csproj @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/tests/XmlComments.UnitTests/XmlDocumentationTests.cs b/src/tests/XmlComments.UnitTests/XmlDocumentationTests.cs new file mode 100644 index 0000000..fab1511 --- /dev/null +++ b/src/tests/XmlComments.UnitTests/XmlDocumentationTests.cs @@ -0,0 +1,72 @@ +namespace Purview.OpenAPI.XmlComments; + +public sealed class XmlDocumentationTests +{ + [Test] + public async Task Load_GivenDocumentationFile_ReturnsItNamedAfterTheFile(CancellationToken cancellationToken) + { + // Arrange + var path = await TestFiles.WriteAsync( + "Lib.A.xml", + TestBuilder.DocumentationText([TestBuilder.TypeId()]), + cancellationToken + ); + + // Act + var documentation = XmlDocumentation.Load(path); + + // Assert + await Assert.That(documentation).IsNotNull(); + await Assert.That(documentation!.Name).IsEqualTo("Lib.A"); + } + + [Test] + [Arguments("")] + [Arguments("")] + [Arguments("")] + public async Task Load_GivenXmlThatIsNotADocumentationFile_ReturnsNull( + string content, + CancellationToken cancellationToken + ) + { + // Arrange + var path = await TestFiles.WriteAsync("Other.xml", content, cancellationToken); + + // Act + var documentation = XmlDocumentation.Load(path); + + // Assert + await Assert.That(documentation).IsNull(); + } + + [Test] + public async Task Load_GivenMissingFile_ReturnsNull() + { + // Arrange + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "Missing.xml"); + + // Act + var documentation = XmlDocumentation.Load(path); + + // Assert + await Assert.That(documentation).IsNull(); + } + + [Test] + public async Task ToFileText_GivenRemovedMember_OmitsItAndKeepsTheRest() + { + // Arrange + var removed = TestBuilder.TypeId(); + var kept = TestBuilder.TypeId(); + var documentation = TestBuilder.Documentation([removed, kept]); + documentation.Members.First().Remove(); + + // Act + var text = documentation.ToFileText(); + + // Assert + await Assert.That(text).DoesNotContain(removed); + await Assert.That(text).Contains(kept); + await Assert.That(text).StartsWith(""); + } +}