diff --git a/.config/api-surface.json b/.config/api-surface.json new file mode 100644 index 0000000..5f8ec91 --- /dev/null +++ b/.config/api-surface.json @@ -0,0 +1,3881 @@ +{ + "version": 1, + "projects": [ + { + "project": "Architecture.CLI", + "responsibility": "Architecture", + "types": [ + { + "name": "ArchitectureCLIAssembly", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [] + }, + { + "name": "Purview.Architecture.CLI.CommandLine", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Field", + "name": "Findings", + "typeName": "System.Int32", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Success", + "typeName": "System.Int32", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "UsageError", + "typeName": "System.Int32", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Method", + "name": "Run", + "typeName": "System.Int32", + "isStatic": true, + "parameters": [ + "System.Collections.Generic.IReadOnlyList", + "System.IO.TextWriter", + "System.IO.TextWriter" + ] + }, + { + "kind": "Property", + "name": "Version", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + } + ] + } + ] + }, + { + "project": "ArchitectureTesting", + "responsibility": "ArchitectureTesting", + "types": [ + { + "name": "ArchitectureTestingAssembly", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [] + }, + { + "name": "Purview.Architecture.Testing.APIAssembly", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIAssembly", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.Collections.Generic.IReadOnlyList&", + "System.Collections.Generic.IReadOnlyList&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIAssembly" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AssemblyName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Attributes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Path", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ProjectName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Types", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIAttribute", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIAttribute", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIAttribute" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "StringArgument", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "TypeName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIBaseline", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.Int32", + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Field", + "name": "CurrentVersion", + "typeName": "System.Int32", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIBaseline", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Capture", + "typeName": "Purview.Architecture.Testing.APIBaseline", + "isStatic": true, + "parameters": [ + "System.Collections.Generic.IReadOnlyDictionary", + "Purview.Architecture.Testing.ArchitecturePolicy" + ] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.Int32&", + "System.Collections.Generic.IReadOnlyList&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIBaseline" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Read", + "typeName": "Purview.Architecture.Testing.APIBaseline", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "Sorted", + "typeName": "Purview.Architecture.Testing.APIBaseline", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToCanonicalJson", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Write", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Property", + "name": "Projects", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "RelativePath", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Property", + "name": "Version", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIBaselineChange", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIBaselineChangeKind", + "System.String", + "System.String", + "System.String" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIBaselineChange", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIBaselineChangeKind&", + "System.String&", + "System.String&", + "System.String&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIBaselineChange" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Detail", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Kind", + "typeName": "Purview.Architecture.Testing.APIBaselineChangeKind", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Project", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Subject", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIBaselineChangeKind", + "kind": "Enum", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Enum", + "interfaces": [], + "members": [ + { + "kind": "Field", + "name": "Added", + "typeName": "Purview.Architecture.Testing.APIBaselineChangeKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Changed", + "typeName": "Purview.Architecture.Testing.APIBaselineChangeKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Removed", + "typeName": "Purview.Architecture.Testing.APIBaselineChangeKind", + "isStatic": true, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIBaselineComparer", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Method", + "name": "Compare", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [ + "Purview.Architecture.Testing.APIBaseline", + "Purview.Architecture.Testing.APIBaseline" + ] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIBaselineMember", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.String", + "System.Boolean", + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIBaselineMember", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.String&", + "System.Boolean&", + "System.Collections.Generic.IReadOnlyList&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIBaselineMember" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsStatic", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Kind", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Name", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Parameters", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "TypeName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIBaselineProject", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIBaselineProject", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.Collections.Generic.IReadOnlyList&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIBaselineProject" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Project", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Responsibility", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Types", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIBaselineType", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.Boolean", + "System.Boolean", + "System.String", + "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIBaselineType", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.Boolean&", + "System.Boolean&", + "System.String&", + "System.Collections.Generic.IReadOnlyList&", + "System.Collections.Generic.IReadOnlyList&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIBaselineType" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "BaseType", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Interfaces", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsAbstract", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsSealed", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Kind", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Members", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Name", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIMember", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "Purview.Architecture.Testing.APIMemberKind", + "System.String", + "System.Boolean", + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIMember", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "Purview.Architecture.Testing.APIMemberKind&", + "System.String&", + "System.Boolean&", + "System.Collections.Generic.IReadOnlyList&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIMember" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Attributes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsStatic", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Kind", + "typeName": "Purview.Architecture.Testing.APIMemberKind", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Name", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Parameters", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "TypeName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIMemberKind", + "kind": "Enum", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Enum", + "interfaces": [], + "members": [ + { + "kind": "Field", + "name": "Constructor", + "typeName": "Purview.Architecture.Testing.APIMemberKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Event", + "typeName": "Purview.Architecture.Testing.APIMemberKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Field", + "typeName": "Purview.Architecture.Testing.APIMemberKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Method", + "typeName": "Purview.Architecture.Testing.APIMemberKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Property", + "typeName": "Purview.Architecture.Testing.APIMemberKind", + "isStatic": true, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIRules", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Baseline", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Enabled", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "RequireMemberDocumentation", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "RequireTypeDocumentation", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APISurface", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Method", + "name": "Discover", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [ + "System.String", + "Purview.Architecture.Testing.AssemblyRules" + ] + }, + { + "kind": "Method", + "name": "Load", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": true, + "parameters": [ + "System.Collections.Generic.IEnumerable", + "System.Collections.Generic.IReadOnlyDictionary" + ] + }, + { + "kind": "Method", + "name": "Read", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "ReadAssembly", + "typeName": "Purview.Architecture.Testing.APIAssembly", + "isStatic": true, + "parameters": [ + "System.String", + "System.Collections.Generic.IReadOnlyDictionary" + ] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APIType", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.String", + "System.String", + "Purview.Architecture.Testing.APITypeKind", + "System.Boolean", + "System.Boolean", + "System.Boolean", + "System.Boolean", + "System.String", + "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.APIType", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.String&", + "System.String&", + "Purview.Architecture.Testing.APITypeKind&", + "System.Boolean&", + "System.Boolean&", + "System.Boolean&", + "System.Boolean&", + "System.String&", + "System.Collections.Generic.IReadOnlyList&", + "System.Collections.Generic.IReadOnlyList&", + "System.Collections.Generic.IReadOnlyList&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.APIType" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Attributes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "BaseType", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "FullName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Interfaces", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsAbstract", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsAttribute", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsException", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsGeneratedCode", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsNested", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsPublic", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsSealed", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsStatic", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Kind", + "typeName": "Purview.Architecture.Testing.APITypeKind", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Members", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Name", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Namespace", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ProjectName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.APITypeKind", + "kind": "Enum", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Enum", + "interfaces": [], + "members": [ + { + "kind": "Field", + "name": "Class", + "typeName": "Purview.Architecture.Testing.APITypeKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Delegate", + "typeName": "Purview.Architecture.Testing.APITypeKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Enum", + "typeName": "Purview.Architecture.Testing.APITypeKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Interface", + "typeName": "Purview.Architecture.Testing.APITypeKind", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Struct", + "typeName": "Purview.Architecture.Testing.APITypeKind", + "isStatic": true, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.ArchitectureContext", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ArchitecturePolicy", + "System.Collections.Generic.IReadOnlyDictionary", + "System.Collections.Generic.IReadOnlyDictionary", + "System.Collections.Generic.IReadOnlyDictionary", + "Purview.Architecture.Testing.APIBaseline" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.ArchitectureContext", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ArchitecturePolicy&", + "System.Collections.Generic.IReadOnlyDictionary&", + "System.Collections.Generic.IReadOnlyDictionary&", + "System.Collections.Generic.IReadOnlyDictionary&", + "Purview.Architecture.Testing.APIBaseline&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ArchitectureContext" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "APISurfaces", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Assemblies", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Baseline", + "typeName": "Purview.Architecture.Testing.APIBaseline", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Policy", + "typeName": "Purview.Architecture.Testing.ArchitecturePolicy", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Projects", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.ArchitecturePolicy", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Classify", + "typeName": "Purview.Architecture.Testing.Responsibility", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "Load", + "typeName": "Purview.Architecture.Testing.ArchitecturePolicy", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "RepositoryRoot", + "typeName": "System.String", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "WaiverFor", + "typeName": "Purview.Architecture.Testing.PolicyWaiver", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Property", + "name": "APIRules", + "typeName": "Purview.Architecture.Testing.APIRules", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AssemblyRules", + "typeName": "Purview.Architecture.Testing.AssemblyRules", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "DependencyBudget", + "typeName": "Purview.Architecture.Testing.DependencyBudget", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "DiscouragedProjectNames", + "typeName": "Purview.Architecture.Testing.DiscouragedNames", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "PolicyIntegrity", + "typeName": "Purview.Architecture.Testing.PolicyIntegritySettings", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "PortRules", + "typeName": "Purview.Architecture.Testing.PortRules", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "RelativePath", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Property", + "name": "Responsibilities", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "SourceRoots", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "TypeRules", + "typeName": "Purview.Architecture.Testing.TypeRules", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Version", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "WaiverPolicy", + "typeName": "Purview.Architecture.Testing.WaiverPolicySettings", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Waivers", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.ArchitecturePolicyEvaluator", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ArchitecturePolicy", + "System.Collections.Generic.IReadOnlyDictionary" + ] + }, + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ArchitecturePolicy", + "System.Collections.Generic.IReadOnlyDictionary", + "System.Collections.Generic.IReadOnlyDictionary", + "System.Collections.Generic.IReadOnlyList", + "System.Nullable", + "System.Collections.Generic.IReadOnlyDictionary", + "Purview.Architecture.Testing.APIBaseline" + ] + }, + { + "kind": "Method", + "name": "APIDocumentation", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "APISurfaceCompatibility", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "AssemblyDependencyMatrix", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "AttributePolicy", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "CompositionBoundary", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Cycles", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "DependencyBudgetRule", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "DependencyMatrix", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "DiscouragedNames", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EnabledAPIRules", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EnabledAdoptionRules", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EnabledPortRules", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EnabledTypeRules", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EvaluateAPIRule", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "EvaluateAdditionalRule", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "EvaluateAdditionalRules", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EvaluateAdoptionRule", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "EvaluateAll", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EvaluatePortRule", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "EvaluateTypeRule", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "ForRepository", + "typeName": "Purview.Architecture.Testing.ArchitecturePolicyEvaluator", + "isStatic": true, + "parameters": [ + "System.String", + "System.Collections.Generic.IReadOnlyList", + "System.Nullable" + ] + }, + { + "kind": "Method", + "name": "ForbiddenFrameworkReferences", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ForbiddenPackages", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "GraphDiscovery", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Naming", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "OwnedPackages", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ProjectNode" + ] + }, + { + "kind": "Method", + "name": "PolicyIntegrity", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "PortImplementation", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "PortPurity", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Report", + "typeName": "System.String", + "isStatic": true, + "parameters": [ + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Method", + "name": "SiblingIndependence", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "TestReferences", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "TypeNamespace", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "TypeNaming", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "TypeShape", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "WaiverIntegrity", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "APISurfaces", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Assemblies", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Baseline", + "typeName": "Purview.Architecture.Testing.APIBaseline", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Policy", + "typeName": "Purview.Architecture.Testing.ArchitecturePolicy", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ProductionProjects", + "typeName": "System.Collections.Generic.IEnumerable", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Projects", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.ArchitectureRuleDescriptor", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.String", + "System.String", + "System.Boolean", + "System.String", + "System.String" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.ArchitectureRuleDescriptor", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.String&", + "System.String&", + "System.Boolean&", + "System.String&", + "System.String&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ArchitectureRuleDescriptor" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "DataSource", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "DocumentationAnchor", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Group", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Rule", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Summary", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Title", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Waivable", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.ArchitectureRules", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Field", + "name": "APIDocumentation", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "APISurface", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "AssemblyDependencyMatrix", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "AttributePolicy", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "CompositionBoundary", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Cycle", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "DependencyBudgetRule", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "DependencyMatrix", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "DiscouragedName", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "ForbiddenFramework", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "ForbiddenPackage", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "GraphDiscovery", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "Naming", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "PolicyIntegrity", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "PortImplementation", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "PortPurity", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "SiblingIndependence", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "TestReference", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "TypeNamespace", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "TypeNaming", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "TypeShape", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Field", + "name": "WaiverIntegrity", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Method", + "name": "Describe", + "typeName": "Purview.Architecture.Testing.ArchitectureRuleDescriptor", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "IsWaivable", + "typeName": "System.Boolean", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Property", + "name": "All", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Property", + "name": "Catalogue", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.ArchitectureViolation", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.String" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.ArchitectureViolation", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.String&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ArchitectureViolation" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Message", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Project", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Rule", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.AssemblyGraph", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Method", + "name": "Discover", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [ + "System.String", + "Purview.Architecture.Testing.AssemblyRules" + ] + }, + { + "kind": "Method", + "name": "Load", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": true, + "parameters": [ + "System.Collections.Generic.IEnumerable", + "System.Collections.Generic.IReadOnlyDictionary" + ] + } + ] + }, + { + "name": "Purview.Architecture.Testing.AssemblyNode", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.Collections.Generic.IReadOnlyList" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.AssemblyNode", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.Collections.Generic.IReadOnlyList&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.AssemblyNode" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AssemblyName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ProjectName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "References", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.AssemblyReference", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.AssemblyReference", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.AssemblyReference" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "TargetProject", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "TypeName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.AssemblyRules", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AssemblyGlobs", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AssemblyRoots", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Enabled", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.DependencyBudget", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Enabled", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Entries", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "FailOnStale", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.DependencyBudgetEntry", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Project", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "References", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.DiscouragedNames", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Names", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Reason", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.IArchitectureRule", + "kind": "Interface", + "isAbstract": true, + "isSealed": false, + "interfaces": [], + "members": [ + { + "kind": "Method", + "name": "Evaluate", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ArchitectureContext" + ] + }, + { + "kind": "Property", + "name": "Rule", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.PackageTarget", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyDictionary>" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.PackageTarget", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.Collections.Generic.IReadOnlyList&", + "System.Collections.Generic.IReadOnlyList&", + "System.Collections.Generic.IReadOnlyList&", + "System.Collections.Generic.IReadOnlyDictionary>&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.PackageTarget" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Direct", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Framework", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Owned", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Resolved", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ThroughReferences", + "typeName": "System.Collections.Generic.IReadOnlyDictionary>", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.PolicyIntegritySettings", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "CheckReachability", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.PolicySchemaValidator", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Field", + "name": "SchemaId", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Method", + "name": "Validate", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [ + "System.String" + ] + } + ] + }, + { + "name": "Purview.Architecture.Testing.PolicyWaiver", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Excuses", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Property", + "name": "MapsTo", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Project", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Reason", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ReviewedOn", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Rules", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.PortRules", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Categories", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Enabled", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ForbiddenTypePrefixes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.ProjectGraph", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Method", + "name": "FindCycles", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [ + "System.Collections.Generic.IReadOnlyDictionary" + ] + }, + { + "kind": "Method", + "name": "Load", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "Load", + "typeName": "System.Collections.Generic.IReadOnlyDictionary", + "isStatic": true, + "parameters": [ + "System.String", + "System.Collections.Generic.IReadOnlyList" + ] + } + ] + }, + { + "name": "Purview.Architecture.Testing.ProjectNode", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String", + "System.String", + "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyList", + "System.Boolean" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.ProjectNode", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.String&", + "System.String&", + "System.Collections.Generic.IReadOnlyList&", + "System.Collections.Generic.IReadOnlyList&", + "System.Boolean&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.ProjectNode" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AnalyzerReferences", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AssemblyName", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "FrameworkReferences", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsTestProject", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Name", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "PackageReferences", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "PackageTargets", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Path", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ProjectReferences", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ResolvedPackages", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "RootNamespace", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.PurviewArchitectureTests", + "kind": "Class", + "isAbstract": true, + "isSealed": false, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Method", + "name": "APIRules_GivenTheRepository_RespectThePolicy", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "AdditionalRuleIds", + "typeName": "System.Collections.Generic.IEnumerable", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "AdditionalRule_GivenTheRepository_RespectsThePolicy", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "AdoptionRules_GivenTheRepository_RespectThePolicy", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "AssemblyTypes_GivenTheirReferences_RespectTheDependencyMatrix", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EnabledAPIRuleIds", + "typeName": "System.Collections.Generic.IEnumerable", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EnabledAdoptionRuleIds", + "typeName": "System.Collections.Generic.IEnumerable", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EnabledPortRuleIds", + "typeName": "System.Collections.Generic.IEnumerable", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "EnabledTypeRuleIds", + "typeName": "System.Collections.Generic.IEnumerable", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Implementations_GivenTheirReferences_DoNotDependOnComposition", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "PolicyWaivers_GivenTheirDefinitions_AreJustifiedAndStillApply", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Policy_GivenItsOwnDefinitions_IsConsistent", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Policy_GivenTheSchema_Validates", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "PortRules_GivenTheRepository_RespectThePolicy", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "ProductionProjects_GivenTheirFrameworkReferences_AvoidForbiddenFrameworks", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ProductionProjects_GivenTheirNames_AvoidCatchAllNames", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ProductionProjects_GivenTheirNames_MapToAResponsibility", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ProductionProjects_GivenTheirPackages_AvoidForbiddenFrameworkDependencies", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ProductionProjects_GivenTheirReferences_DoNotReferenceATestProject", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ProductionProjects_GivenTheirReferences_RespectTheDependencyMatrix", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ProjectGraph_GivenItsReferences_HasNoCycles", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Repository_GivenItsSourceRoots_YieldsProjectsToCheck", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "SiblingImplementations_GivenTheirReferences_DoNotDependOnEachOther", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "TypeRules_GivenTheRepository_RespectThePolicy", + "typeName": "System.Threading.Tasks.Task", + "isStatic": false, + "parameters": [ + "System.String" + ] + } + ] + }, + { + "name": "Purview.Architecture.Testing.Responsibility", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Aliases", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Category", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ForbiddenFrameworkReferences", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ForbiddenPackagePrefixes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ForbiddenPackageScope", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "MayDependOn", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "MayRemainCombinedWhen", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "MustNotDependOn", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Name", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Purpose", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "SeparateProjectWhen", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "SiblingIndependence", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.TypeRuleScope", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Categories", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.TypeRules", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AllowObsoleteWithoutMessage", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "AppliesTo", + "typeName": "Purview.Architecture.Testing.TypeRuleScope", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "DiscouragedTypeSuffixes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Enabled", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ExcludeGeneratedCode", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ExcludeNamespacePrefixes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ForbidInternalsVisibleToProduction", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "ForbiddenAttributes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "RequireReadOnlyCollections", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "RequiredAttributes", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "SealConcreteClasses", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.WaiverPolicySettings", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "MaxAgeDays", + "typeName": "System.Nullable", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.WaiverReview", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.PolicyWaiver", + "System.Boolean", + "System.String" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Testing.WaiverReview", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.PolicyWaiver&", + "System.Boolean&", + "System.String&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Testing.WaiverReview" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "IsStale", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Reason", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Waiver", + "typeName": "Purview.Architecture.Testing.PolicyWaiver", + "isStatic": false, + "parameters": [] + } + ] + }, + { + "name": "Purview.Architecture.Testing.WaiverReviewer", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Field", + "name": "DateFormat", + "typeName": "System.String", + "isStatic": true, + "parameters": [] + }, + { + "kind": "Method", + "name": "Review", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": true, + "parameters": [ + "Purview.Architecture.Testing.ArchitecturePolicy", + "System.DateOnly" + ] + } + ] + }, + { + "name": "Purview.Architecture.Testing.XMLDocumentation", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Method", + "name": "Exists", + "typeName": "System.Boolean", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "FormatType", + "typeName": "System.String", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "MemberId", + "typeName": "System.String", + "isStatic": true, + "parameters": [ + "Purview.Architecture.Testing.APIType", + "Purview.Architecture.Testing.APIMember" + ] + }, + { + "kind": "Method", + "name": "Read", + "typeName": "System.Collections.Generic.IReadOnlySet", + "isStatic": true, + "parameters": [ + "System.String" + ] + }, + { + "kind": "Method", + "name": "TypeId", + "typeName": "System.String", + "isStatic": true, + "parameters": [ + "Purview.Architecture.Testing.APIType" + ] + } + ] + } + ] + } + ] +} diff --git a/.config/architecture-policy.json b/.config/architecture-policy.json index 719fce3..2644a75 100644 --- a/.config/architecture-policy.json +++ b/.config/architecture-policy.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", - "version": 2, + "version": 3, "description": "The architecture policy this repository applies to itself, through the package it ships.", "conventions": { "qualifiedNameSeparator": ".", @@ -14,6 +14,13 @@ "purpose": "The architecture rule engine: policy reading, the project and assembly graph, and the TUnit rule set.", "mayDependOn": [], "mustNotDependOn": [] + }, + { + "name": "Architecture", + "category": "host", + "purpose": "The architests command-line host that runs the evaluator outside TUnit.", + "mayDependOn": ["ArchitectureTesting"], + "mustNotDependOn": [] } ], "discouragedProjectNames": { @@ -22,5 +29,29 @@ }, "assemblyRules": { "enabled": true + }, + "typeRules": { + "enabled": true, + "appliesTo": { + "categories": ["shared", "host"] + }, + "excludeGeneratedCode": true + }, + "apiRules": { + "enabled": true + }, + "portRules": { + "enabled": true + }, + "dependencyBudget": { + "enabled": true, + "failOnStale": true, + "entries": [] + }, + "policyIntegrity": { + "checkReachability": true + }, + "waiverPolicy": { + "maxAgeDays": 180 } } diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 118be8c..f8a4785 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,11 +17,11 @@ "rollForward": false }, "purview.build": { - "version": "0.5.4", + "version": "0.7.1", "commands": [ "purview-build" ], "rollForward": false } } -} +} \ No newline at end of file diff --git a/.config/purview-build.json b/.config/purview-build.json index 231c7fc..e533bbd 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -3,7 +3,7 @@ "Solution": "src/ArchitectureTesting.slnx", "TestRoot": "src", "TestPatterns": "*Tests.csproj", - "TestFilter": "/*/*/*/*[Category=Unit]/" + "TestFilter": "/*/*/*/*/" }, "PackValidation": { "RequiredCompilerFlags": [ @@ -17,6 +17,11 @@ "lib/$(TFM)/Purview.Architecture.Testing.xml", "content/architecture-policy.schema.json", "purview-logo-light.png" + ], + "purview.architecture.testing.cli": [ + "README.md", + "purview-logo-light.png", + "tools/net10.0/any/*" ] } }, diff --git a/.csharpierignore b/.csharpierignore deleted file mode 100644 index 8229a2c..0000000 --- a/.csharpierignore +++ /dev/null @@ -1,2 +0,0 @@ -# Generated code produced by the repository's own source generator tests -src/tests/SourceGenerator.UnitTests/**/Generated/* \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 120d509..b511789 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,23 @@ +# Purview.BuildSdk 1.1.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 @@ -360,12 +380,8 @@ 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 -csharp_prefer_system_threading_lock = true dotnet_style_prefer_collection_expression = true:suggestion -# Enable foreach explicit cast preference -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning - # Prefer System.Threading.Lock csharp_prefer_system_threading_lock = true:warning @@ -497,7 +513,7 @@ 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 +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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c03a27..514a4cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,13 @@ on: push: branches: [main] +permissions: + contents: write + # The shared release workflow pushes the same packages to nuget.org and, when + # PURVIEW_PUBLISH_GITHUB_PACKAGES=true, to GitHub Packages. A reusable workflow cannot grant + # more than its caller, so the permission is declared here. + packages: write + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/AGENTS.md b/AGENTS.md index cac191f..82ac998 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,13 +19,16 @@ derived test class. The repository also applies that package to itself. | Path | Purpose | | --- | --- | | `src/ArchitectureTesting.slnx` | Canonical solution for restore, build, test, and pack | -| `src/src/ArchitectureTesting` | The package: `ArchitecturePolicy`, `ProjectGraph`, `AssemblyGraph`, `ArchitecturePolicyEvaluator`, `PurviewArchitectureTests` | +| `src/src/ArchitectureTesting` | The package: `ArchitecturePolicy`, `ProjectGraph`, `AssemblyGraph`, `APISurface`, `ArchitecturePolicyEvaluator`, `PurviewArchitectureTests` | +| `src/src/Architecture.CLI` | The `architests` dotnet tool: `validate`, `waivers list`; assembly and command are `architests`, package ID is `Purview.Architecture.Testing.Cli` | | `src/tests/ArchitectureTesting.UnitTests` | Rule tests against in-memory and fixture graphs, and the reader tests | -| `src/tests/Repository.UnitTests` | Repository-level assertions: the skill self-tests and the package applied to this repository | +| `src/tests/ArchitectureTesting.CLI.UnitTests` | The tool's parser, exit codes, `validate` and `waivers list` | +| `src/tests/Repository.UnitTests` | Repository-level assertions: the skill self-tests, the rule/docs drift test, and the package applied to this repository | | `src/samples/ArchitectureSample` | A small, fully working repository that adopts the package | -| `docs/` | The documentation site (MkDocs): getting started, policy reference, type-level rules, extending | +| `docs/` | The documentation site (MkDocs): getting started, policy reference, type-level rules, command line, extending | | `architecture-policy.schema.json` | The published policy schema, embedded into the package as a resource | | `.config/architecture-policy.json` | This repository's own policy, exercised by the dogfood test | +| `.config/api-surface.json` | The committed public API baseline, compared by `apiSurface` and `architests api check` | | `skills/` | The agent skills this repository publishes | | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package, analyzer, and build behavior | @@ -40,7 +43,8 @@ derived test class. The repository also applies that package to itself. 2. Confirm behavior from code and tests rather than relying on memory or documentation alone. 3. Make the smallest coherent change. Preserve public behavior unless the task explicitly changes it. 4. Update tests for fixes and behavior changes. Update documentation when public behavior changes. -5. Run the narrowest meaningful validation first, then broader validation in proportion to risk. +5. Run the narrowest meaningful validation first — `architests validate` for a policy change — then + broader validation in proportion to risk. 6. Review the diff for unrelated edits, generated noise, compatibility risks, and missing docs or tests. ## The three-layer design (non-negotiable) @@ -57,6 +61,23 @@ Keep the layers separated, because the separation is what makes the rules testab Every new rule must be evaluable from an in-memory fixture and asserted by a small `ArchitectureTesting.UnitTests` test, exactly as the existing rules are. +## Dogfooding (non-negotiable) + +- The package applies every rule it ships to this repository, through + `.config/architecture-policy.json` and `src/tests/Repository.UnitTests/ArchitectureTests.cs`. +- A change that adds a rule enables it in the dogfood policy in the same commit. A rule that cannot + pass here is not ready to ship. +- The repository is clean under every enabled rule: fix the violation, or record a waiver with a + reason and a review date. `SkipRule` is not used in the dogfood test. +- The dogfood test project references every production project, so the type-level and surface + readers see each assembly in its output directory. A production project missing from that reference + list escapes every metadata rule silently. +- The rule catalogue, `docs/`, both READMEs and the skills must list every rule id; + `RuleDocumentationTests` fails on drift. Add the id to the catalogue and to the documents in the + same change. +- Public API baseline changes, once a baseline exists, are ordinary reviewed changes: deliberate, in + the same commit, and explained in the pull request. + ## Reading assemblies - Read compiled assemblies by **metadata only** (`System.Reflection.Metadata`). Never `Assembly.Load`, @@ -120,6 +141,10 @@ Use `dotnet csharpier check .` for validation and `dotnet csharpier format .` to pipeline with `Release:Mode=NuGet`. - Never create release tags or publish packages manually unless the user explicitly requests a documented recovery procedure. +- The release version is whatever `package.json` says; never infer it from this document, and do not + bump it as part of an unrelated change. +- The architecture policy document version is **3**. Bump it when a change would break a reader that + understood the previous shape; adding optional sections does not require a bump. ## Agent skills @@ -139,9 +164,13 @@ npx skills add purview-dev/architests | Skill | Answers | | -- | -- | | `skills/purview-architests/` | adopting, configuring and debugging the package: the policy, the rules, waivers, the type-level rule, reading a failure | +| `skills/purview-architests-policy-authoring/` | writing and repairing the policy: responsibilities, the matrix, policy integrity, waiver expiry | +| `skills/purview-architests-type-rules/` | enabling and debugging `typeRules`: naming, shape, namespace, attributes, and which public types are governed | +| `skills/purview-architests-contracts/` | the committed API baseline, documentation requirements, and the port rules | +| `skills/purview-architests-migration/` | adopting incrementally in an existing repository, and ratcheting a policy tighter | | `skills/purview-architests-extending/` | adding a bespoke rule, the `ArchitectureContext`, and the package's internals | -Both carry the `purview-architests` prefix, because skills install into one flat directory per agent +All carry the `purview-architests` prefix, because skills install into one flat directory per agent shared with every skill a developer has installed from anywhere. `src/tests/Repository.UnitTests/SkillTests.cs` validates them on every test run through `Purview.Skills.Testing`'s rule set, and asserts the prefix. The `[InheritsTests]` attribute on that class is load-bearing — TUnit does not walk base types during @@ -158,7 +187,11 @@ Before handing work back: - Confirm only intended files changed. - Review public API, schema, and package-content implications. - Add or update focused tests for code changes. -- Update `README.md`, the package README, the skill and the schema when public behavior changes. +- Update `README.md`, the package README, the skills and the schema when public behavior changes; + the rule catalogue and the documents are checked for drift by `RuleDocumentationTests`. +- Add every new rule to `ArchitectureRules.Catalogue` and enable it in the dogfood policy. +- Run `architests validate`, `architests waivers list --stale` and `architests api check` against this + repository. Regenerate `.config/api-surface.json` only for an intended public API change. - Run appropriate build, test, formatting, and pack checks in proportion to risk. - State exactly what validation ran. If a check was skipped or blocked, give the concrete reason and remaining risk. diff --git a/Directory.Packages.props b/Directory.Packages.props index 17e428e..27b891d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,26 +1,18 @@ - - true - true - 1.75.0 + 1.76.0 9.4.1 - - - - - + diff --git a/README.md b/README.md index 3653854..949a375 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ copied into every repository's test project. another. - **Forbidden packages and framework references** — e.g. `Microsoft.AspNetCore.App` in a portable layer. +- **Type naming and shape** — interface, attribute and exception conventions; sealed classes, + read-only collections, root namespaces and attributes, read from metadata. - **Cycles, naming and test references** — the rest of the standard set, reported readably. - **Waivers** — reviewed, per-project departures that keep every other rule in force and are themselves checked. @@ -29,7 +31,7 @@ Adoption is two things: a policy file and one derived class. ```json { "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", - "version": 2, + "version": 3, "sourceRoots": ["src"], "responsibilities": [ { @@ -60,13 +62,54 @@ public sealed class ArchitectureTests : PurviewArchitectureTests; dotnet add package Purview.Architecture.Testing ``` +The same policy can be checked outside a test run: + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli +architests validate +architests waivers list --stale +``` + +Exit codes are part of the contract: `0` clean, `1` findings, `2` a malformed command line. + +## Rules + +| Rule id | Catches | +| -- | -- | +| `policyIntegrity` | A policy that names an unknown responsibility, permits and forbids the same dependency, or declares a duplicate name or alias | +| `graphDiscovery` | No production projects discovered, so every other rule would pass vacuously | +| `naming` | A project name that maps to no responsibility or alias | +| `discouragedName` | A project named after a category rather than a responsibility | +| `dependencyMatrix` | A declared `ProjectReference` the matrix forbids or does not permit | +| `assemblyDependencyMatrix` | A type-level reference the matrix forbids or does not permit, including one that arrives transitively | +| `typeNaming` | A public interface without `I`, an attribute or exception without its suffix, or a discouraged suffix such as `Manager` | +| `typeShape` | An unsealed public class, a public field, a mutable collection return, or a utility class that is not static | +| `typeNamespace` | A public type declared outside its project's root namespace | +| `attributePolicy` | A missing or forbidden attribute, `[Obsolete]` without a message, or `InternalsVisibleTo` naming production code | +| `apiSurface` | The public API differs from the committed baseline | +| `apiDocumentation` | A public type or member has no XML documentation | +| `portImplementation` | A port in an inner layer has no implementation | +| `portPurity` | A port exposes an infrastructure technology | +| `dependencyBudget` | A project references beyond its budget, or the budget still allows a reference that has gone | +| `siblingIndependence` | A qualified implementation referencing a qualified sibling | +| `compositionBoundary` | A non-composition, non-host project referencing the composition root | +| `forbiddenPackage` | A package the responsibility owns matching a forbidden prefix | +| `forbiddenFramework` | A framework reference matching a forbidden prefix for the responsibility | +| `cycle` | A cycle in the project graph | +| `testReference` | A production project referencing a test project | +| `waiverIntegrity` | A stale, unjustified or malformed waiver, or one excusing an unknown or unwaivable rule | + ## Documentation - [Getting started](docs/Getting-Started.md) — adopt the package in a repository, step by step. +- [Tutorial](docs/Tutorial.md) — grow one application and its policy from a two-project matrix to type, API, port and budget rules. - [Policy reference](docs/Policy-Reference.md) — every field, every rule id, every default. - [Type-level rules](docs/Type-Level-Rules.md) — how the assembly check works and how an assembly is attributed to a project. +- [API contracts](docs/Contracts.md) — the committed baseline, XML documentation and the port rules. +- [Migration and budget](docs/Migration.md) — adopt incrementally and ratchet the project graph. - [Extending](docs/Extending.md) — add your own rule to the standard set. +- [Command line](docs/Cli.md) — validate, capture the API, review waivers and render the graph. - [The policy schema](architecture-policy.schema.json) — the published JSON Schema. ## Sample @@ -83,8 +126,11 @@ dotnet test src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample - `src/src/ArchitectureTesting` — the package: policy reader, project and assembly graph, rule evaluator, and the TUnit base class. +- `src/src/Architecture.CLI` — the `architests` dotnet tool. - `src/tests/ArchitectureTesting.UnitTests` — rule tests against in-memory and fixture graphs. -- `src/tests/Repository.UnitTests` — the skill self-tests and the package applied to this repository. +- `src/tests/ArchitectureTesting.CLI.UnitTests` — the tool's parser and commands. +- `src/tests/Repository.UnitTests` — the skill self-tests, the rule/documentation drift test, and the + package applied to this repository. - `src/samples/ArchitectureSample` — the adoption sample. - `architecture-policy.schema.json` — the published policy schema, embedded into the package. - `skills/` — the agent skills this repository publishes. diff --git a/architecture-policy.schema.json b/architecture-policy.schema.json index 4cd1cee..9732b21 100644 --- a/architecture-policy.schema.json +++ b/architecture-policy.schema.json @@ -14,7 +14,7 @@ "version": { "type": "integer", "minimum": 1, - "description": "Schema version of this document. Increment when a change would break a reader that understood the previous shape. Version 2 removed the 'rules' object, whose flags were never consulted; use SkipRule, or a waiver, to opt out of a rule." + "description": "Schema version of this document. Increment when a change would break a reader that understood the previous shape. Version 3 adds typeRules, apiRules, portRules, dependencyBudget, policyIntegrity and waiverPolicy; a version-2 document remains valid because all are optional. Version 2 removed the 'rules' object, whose flags were never consulted; use SkipRule, or a waiver, to opt out of a rule." }, "description": { "type": "string" @@ -88,6 +88,204 @@ } } }, + "typeRules": { + "type": "object", + "additionalProperties": false, + "description": "The type-level naming, shape, namespace and attribute rules. Off by default because they read compiled assemblies and need a build to have produced them.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Whether the type-level rules run. They read the definition tables by metadata only: nothing is loaded or executed." + }, + "appliesTo": { + "type": "object", + "additionalProperties": false, + "description": "Which responsibilities the rules govern. An empty categories array governs every production responsibility.", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": ["domain", "application", "infrastructure", "composition", "host", "shared"] + }, + "uniqueItems": true, + "default": [] + } + } + }, + "excludeGeneratedCode": { + "type": "boolean", + "default": true, + "description": "Skip types marked GeneratedCode or CompilerGenerated, so a source generator is not forced to change." + }, + "excludeNamespacePrefixes": { + "type": "array", + "description": "Namespace prefixes whose types are skipped, such as a generated-client namespace.", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "discouragedTypeSuffixes": { + "type": "array", + "description": "Type-name suffixes that describe a mechanism rather than a responsibility.", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true, + "default": ["Manager", "Helper", "Util", "Impl"] + }, + "sealConcreteClasses": { + "type": "boolean", + "default": true, + "description": "Require a public concrete class to be sealed, unless it is abstract." + }, + "requireReadOnlyCollections": { + "type": "boolean", + "default": true, + "description": "Reject a public property or method that exposes List or Dictionary; return IReadOnlyList or IReadOnlyDictionary instead." + }, + "forbiddenAttributes": { + "type": "array", + "description": "Attributes no governed public type may carry, by namespace-qualified type name.", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "requiredAttributes": { + "type": "array", + "description": "Attributes every governed public type must carry, by namespace-qualified type name.", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "allowObsoleteWithoutMessage": { + "type": "boolean", + "default": false, + "description": "Allow [Obsolete] without a message. Off by default: an obsolete member without a replacement is a dead end." + }, + "forbidInternalsVisibleToProduction": { + "type": "boolean", + "default": true, + "description": "Report InternalsVisibleTo that names a production assembly. Exposing internals to a test project remains ordinary." + } + } + }, + "apiRules": { + "type": "object", + "additionalProperties": false, + "description": "The committed-API rules. The baseline is the reviewed record of the public surface, so an addition is as deliberate as a removal. Off by default because it needs a build.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Whether the API rules run." + }, + "baseline": { + "type": "string", + "minLength": 1, + "default": ".config/api-surface.json", + "description": "The baseline path relative to the repository root." + }, + "requireTypeDocumentation": { + "type": "boolean", + "default": true, + "description": "Require every public type to have an XML documentation entry. The project must generate its documentation file." + }, + "requireMemberDocumentation": { + "type": "boolean", + "default": false, + "description": "Require every public member to have an XML documentation entry. Off by default: a record's positional parameters document its properties through the type entry." + } + } + }, + "portRules": { + "type": "object", + "additionalProperties": false, + "description": "The port rules: every interface in a port category has an implementation, and its signature names no infrastructure technology.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Whether the port rules run." + }, + "categories": { + "type": "array", + "description": "The categories whose interfaces are treated as ports. Defaults to the inner layers.", + "items": { + "type": "string", + "enum": ["domain", "application", "infrastructure", "composition", "host", "shared"] + }, + "uniqueItems": true, + "default": ["domain", "application"] + }, + "forbiddenTypePrefixes": { + "type": "array", + "description": "Type prefixes a port must not expose, such as a database provider or an HTTP client.", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true, + "default": ["Microsoft.EntityFrameworkCore", "System.Data", "Microsoft.AspNetCore", "System.Net.Http"] + } + } + }, + "dependencyBudget": { + "type": "object", + "additionalProperties": false, + "description": "A ratchet on each project's declared references. The budget records what the graph allows today, so a new dependency needs a reviewed change and a stale allowance is removed.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Whether the budget is enforced." + }, + "failOnStale": { + "type": "boolean", + "default": true, + "description": "Fail when an allowed reference no longer exists. A stale allowance hides the next dependency." + }, + "entries": { + "type": "array", + "description": "The per-project allowances. A project with no entry is not budgeted; an empty array is a valid starting point.", + "items": { + "type": "object", + "required": ["project", "references"], + "additionalProperties": false, + "properties": { + "project": { + "type": "string", + "minLength": 1, + "description": "The production project name, exactly as it appears on disk." + }, + "references": { + "type": "array", + "description": "The project references the budget permits.", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } + } + } + } + }, + "policyIntegrity": { + "type": "object", + "additionalProperties": false, + "description": "The policy's own consistency checks. Unknown responsibility names, a dependency that is both permitted and forbidden, and duplicate names or aliases are always reported; reachability is opt-in.", + "properties": { + "checkReachability": { + "type": "boolean", + "default": false, + "description": "Prove each mustNotDependOn is enforceable: a prohibition is meaningless when the permitted mayDependOn closure can reach the forbidden responsibility anyway." + } + } + }, + "waiverPolicy": { + "type": "object", + "additionalProperties": false, + "description": "How long a waiver may go unreviewed. Unset by default, so expiry is opt-in; a policy that adopts it turns reviewedOn from a note into an obligation.", + "properties": { + "maxAgeDays": { + "type": "integer", + "minimum": 1, + "description": "The maximum age, in days, of a waiver's reviewedOn date. A stale waiver is reported by waiverIntegrity and by 'architests waivers list --stale'." + } + } + }, "waivers": { "type": "array", "description": "Deliberate, reviewed departures. A waiver excuses the narrowest thing possible - usually the name - and mapsTo keeps the project classified so every dependency rule still applies.", @@ -134,6 +332,12 @@ "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "forbiddenPackageScope": { + "type": "string", + "enum": ["own", "closure"], + "default": "own", + "description": "What forbiddenPackagePrefixes measures. 'own' checks the packages the project declares or its own package references bring in, excluding packages explained by a permitted project reference; 'closure' checks the whole restore closure, including packages inherited from referenced projects." + }, "forbiddenFrameworkReferences": { "type": "array", "description": "Framework reference prefixes that must not appear in this responsibility, such as Microsoft.AspNetCore.App in a portable layer.", diff --git a/docs/Cli.md b/docs/Cli.md new file mode 100644 index 0000000..004ca4b --- /dev/null +++ b/docs/Cli.md @@ -0,0 +1,104 @@ +# Command line + +`architests` runs the parts of the policy that need no build: the schema check, the policy's own +integrity rules, and the waiver review. The same logic backs the TUnit rules, so the tool and the +test suite cannot disagree. + +## Install + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli +``` + +## Commands + +```text +architests validate # schema + policyIntegrity +architests api capture # write .config/api-surface.json +architests api check [--format text|json|sarif] +architests waivers list [--stale] # the policy's waivers and their review state +architests graph [--format text|mermaid] +architests --help +architests --version +``` + +Both commands accept `--repository ` (or `-r`) to name the repository root. Without it, the +tool walks up from the current directory to the folder holding `.config/architecture-policy.json`, +and stops at the repository boundary rather than reading a parent folder's policy. + +### `validate` + +```bash +architests validate +``` + +1. Validates the policy against the schema embedded in the package. +2. Checks the policy's own consistency: unknown responsibility names, a dependency that is both + permitted and forbidden, duplicate names or aliases, and — when `policyIntegrity.checkReachability` + is on — a prohibition the permitted dependencies can bypass. + +It needs no build and no project graph, which makes it the right check for a pre-commit hook or a +fast CI job. + +### `api capture` and `api check` + +```bash +architests api capture +architests api check +``` + +`capture` writes the committed baseline from the compiled assemblies. `check` compares the build +with it and exits `1` on any added, removed or changed public API. The document is canonical, so an +unchanged API regenerates byte for byte. + +The commands scan the repository's own build output, even where `assemblyRules.assemblyRoots` is +empty (the test rule treats that as the test output directory). Build the solution first, and pass +`--baseline ` to use a baseline other than the policy's `apiRules.baseline`. + +`check --format json` prints the differences as JSON, and `--format sarif` prints a SARIF 2.1.0 +document for a code-scanning annotation step. A removal is reported at `error` level; an addition or a +shape change is a `warning`. + +### `graph` + +```bash +architests graph +architests graph --format mermaid +``` + +Renders the project graph from the policy's `sourceRoots`. The default text output lists each project +with its responsibility and category, then its references. `--format mermaid` emits a flowchart for a +pull request or the documentation site. + +### `waivers list` + +```bash +architests waivers list +architests waivers list --stale +``` + +Lists each waiver with its rules and review state. `--stale` shows only the waivers that are due +under `waiverPolicy.maxAgeDays`, and exits non-zero when any is, so it works as a gate. + +A waiver is stale when it is older than the maximum, has no `reviewedOn` while a maximum is +configured, carries a date that is not `yyyy-MM-dd`, or claims a review in the future. + +## Exit codes + +| Code | Meaning | +| -- | -- | +| `0` | The check passed. | +| `1` | Findings: the policy is invalid, inconsistent, or a stale waiver was requested. | +| `2` | A malformed command line: unknown command, unknown option, or a missing option value. | + +The split matters in CI: `1` is a policy problem to fix, `2` is a broken invocation to fix. + +## CI example + +```yaml +- name: Validate the architecture policy + run: | + dotnet tool install --global Purview.Architecture.Testing.Cli + architests validate + architests waivers list --stale +``` diff --git a/docs/Contracts.md b/docs/Contracts.md new file mode 100644 index 0000000..0d4e84e --- /dev/null +++ b/docs/Contracts.md @@ -0,0 +1,129 @@ +# API contracts + +Dependency rules say which project may reference which. The contract rules say what the public +surface actually **is**, and whether the interfaces that define a boundary are honest. They read the +compiled assemblies by metadata only and are off by default, because they need a build. + +| Rule | Group | Catches | +| -- | -- | -- | +| `apiSurface` | `apiRules` | The public API differs from the committed baseline | +| `apiDocumentation` | `apiRules` | A public type or member has no XML documentation | +| `portImplementation` | `portRules` | A port in an inner layer has no public implementation | +| `portPurity` | `portRules` | A port's signature names an infrastructure technology | + +```json +"apiRules": { + "enabled": true +}, +"portRules": { + "enabled": true +} +``` + +Each enabled rule is its own TUnit test. A disabled group produces no test, so an absent `apiSurface` +test means the group is off, not that the surface is unchecked. + +## The committed baseline + +`.config/api-surface.json` records every public type and member, grouped by project, with the +responsibility recorded for review context. It is the contract: a change to it is a change to what a +consumer may call. + +- **Captured, never hand-written.** `architests api capture` writes it. +- **Canonical.** Projects, types, interfaces and members are sorted; there are no timestamps; line + endings are always `\n`. An unchanged API regenerates byte for byte, so a diff means a real change. +- **Committed.** It is reviewed like code. A pull request that changes it explains why. + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli + +architests api capture # write the baseline from the current build +architests api check # compare; exit 1 on any difference +``` + +`api check` is the CI gate; `apiSurface` is the same comparison as a test. A missing baseline is +itself an `apiSurface` failure, because the rule would otherwise pass without checking anything. + +`api capture` and `api check` accept `--repository ` and `--baseline `. Unlike a test run, +a command scans the repository's own build output when `assemblyRules.assemblyRoots` is empty, because +a tool does not run from a test output directory. + +## Reading a baseline failure + +The message names the project, the type or member, and whether it was added, removed or changed. + +- **Added.** The API grew. Run `architests api capture`, inspect the added entries, and commit them + with the feature. Additions are not breaking, but they are still a reviewed contract change. +- **Removed.** The message calls it breaking. Restore the member, or capture the baseline as a + deliberate breaking release and say so in the pull request. +- **Changed.** The shape moved: kind, `sealed`, `abstract`, base type, interfaces, a member's type, or + whether it is static. Treat it like a removal until proven otherwise. + +`api check --format sarif` emits SARIF 2.1.0 for a code-scanning annotation step: removals at +`error`, additions and changes at `warning`. + +## API documentation + +`apiDocumentation` reads the XML documentation file the compiler writes beside each assembly. + +- `requireTypeDocumentation` (default true) requires a `` entry for every public type. +- `requireMemberDocumentation` (default false) extends that to every public member. + +Member documentation is off by default because a record's positional parameters document its +properties through the type's entry; requiring a separate entry per generated property would punish +a correct record. + +A project that ships no XML file is reported once with the fix: set +`true`. The Purview SDK does this already. + +## Ports + +A **port** is an interface in one of `portRules.categories`, which default to `domain` and +`application`: the layers that own contracts. An interface in `shared` or in an adapter is not a port +by this definition. + +### `portImplementation` + +Every port needs at least one **public implementation** in a production project. An unimplemented port +is either dead or a boundary nobody crossed; both deserve a review. The dependency matrix still +governs where that implementation may live. + +An extension point implemented only by consumers (such as this package's `IArchitectureRule`) is not a +good fit: keep it out of the port categories, or record a narrow waiver. + +### `portPurity` + +A port's property types, return types and parameter types must not name a forbidden prefix. Defaults +cover `Microsoft.EntityFrameworkCore`, `System.Data`, `Microsoft.AspNetCore` and `System.Net.Http`. +Generic arguments count, so `IReadOnlyList` is caught. + +```json +"portRules": { + "enabled": true, + "forbiddenTypePrefixes": ["Microsoft.EntityFrameworkCore", "System.Data"] +} +``` + +A port that names a database or an HTTP client is not a boundary; it is a leak. Move the technology +into the adapter and keep the interface in terms the domain understands. + +## In the sample + +[`ArchitectureSample`](https://github.com/purview-dev/architests/tree/main/src/samples/ArchitectureSample) +shows all four rules: + +- `Domain.IOrderRepository` is the port, implemented by `Persistence.InMemory` and + `Persistence.Json`; +- `Application.PlaceOrderHandler` depends on the port, never on an adapter; +- `Composition` selects the adapter; +- `.config/api-surface.json` is the committed baseline for all six projects. + +## CI + +```yaml +- run: architests api check +``` + +`api check` needs a build but no test run. In the test suite, `apiSurface` performs the same +comparison, so the command exists for a fast job and for a reviewer who wants the diff without +running TUnit. diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index aa537e3..f3e5a90 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -19,7 +19,7 @@ each responsibility and what it may depend on: ```json { "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", - "version": 2, + "version": 3, "sourceRoots": ["src"], "responsibilities": [ { @@ -83,10 +83,19 @@ collapsing the set into one assertion. dotnet test ``` -If `graphDiscovery` fails, `sourceRoots` is wrong. If `naming` fails, a project name does not map to -a responsibility — add an alias or a waiver. If `dependencyMatrix` or `assemblyDependencyMatrix` -fails, the message names the offending reference, the direction the dependency should run, and the -likely fix. +If `graphDiscovery` fails, `sourceRoots` is wrong. If `policyIntegrity` fails, the policy itself +contradicts: an unknown name, a dependency both permitted and forbidden, or a duplicate name or +alias. If `naming` fails, a project name does not map to a responsibility — add an alias or a waiver. +If `dependencyMatrix` or `assemblyDependencyMatrix` fails, the message names the offending reference, +the direction the dependency should run, and the likely fix. + +The same policy can be checked without a test run, which is useful in a pre-commit hook or a CI step +that does not build the solution: + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli +architests validate +``` ## 5. Turn on the type-level rule @@ -103,6 +112,51 @@ An empty `assemblyRoots` scans the test output directory, which already holds ev project references. See [Type-level rules](Type-Level-Rules.md) for how an assembly is attributed to a project. +## 6. Turn on the type rules (optional) + +The same compiled assemblies reveal the public API a project declares. To police it: + +```json +"typeRules": { + "enabled": true, + "appliesTo": { "categories": ["domain", "application", "shared"] } +} +``` + +This enables `typeNaming`, `typeShape`, `typeNamespace` and `attributePolicy`. They check only public +types, skip generated code, and produce no test case when the group is off. See the +[policy reference](Policy-Reference.md#type-rules) for every setting. + +## 7. Commit the API baseline (optional) + +To make a change to the public API a reviewed event, commit a baseline: + +```json +"apiRules": { + "enabled": true +} +``` + +Then write it with the tool and commit the result: + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli +architests api capture +architests api check +``` + +`apiSurface` compares every build with `.config/api-surface.json`; `apiDocumentation` requires a +`` on every public type. Enable `portRules` as well to require an implementation for each +interface in the `domain` and `application` categories and to reject infrastructure types in their +signatures. See the [policy reference](Policy-Reference.md#api-rules). + +Adopting in a repository that already has tangled dependencies? Start with +[Migration and the dependency budget](Migration.md): describe today's graph, then tighten one rule at +a time. The contract rules are covered in [API contracts](Contracts.md). + +For a worked example that starts with two projects and grows the same application through adapters, +siblings, composition, type rules, contracts and a budget, follow the [tutorial](Tutorial.md). + ## Overrides ```csharp diff --git a/docs/Migration.md b/docs/Migration.md new file mode 100644 index 0000000..5391711 --- /dev/null +++ b/docs/Migration.md @@ -0,0 +1,138 @@ +# Migration and the dependency budget + +A policy that describes the architecture you wish you had fails on day one and gets switched off. +Adopt the architecture you **do** have, then tighten it one reviewed step at a time. + +## 1. Inventory + +- List every production project and what it references. `architests graph` prints the graph once the + policy can find the projects. +- Group projects into responsibilities that describe what they are for, not where they sit. A + qualified name (`Persistence.SqlServer`) belongs to `Persistence`; only the first segment counts. +- Name the composition root with `category: "composition"` and hosts with `category: "host"`. + +## 2. Write a permissive first policy + +Start with the full dependency closure permitted, so the first run fails only on what you are ready +to fix. + +```json +{ + "version": 3, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Domain", + "category": "domain", + "purpose": "The domain model and its invariants.", + "mayDependOn": ["Application", "Persistence", "Composition"], + "mustNotDependOn": [] + } + ] +} +``` + +Set `sourceRoots` correctly first. An empty graph satisfies every rule, which is why `graphDiscovery` +fails instead of passing silently. + +## 3. Record the API before enforcing it + +If the public API is already shipped, capture it before enabling `apiRules`: + +```bash +architests api capture +``` + +Then enable the group. The first `apiSurface` run compares against the API you actually have, so it +passes, and every later change becomes a reviewed diff. + +## 4. Waivers, not skips + +When a rule finds a real departure you cannot fix today, record it: + +```json +"waivers": [ + { + "project": "Legacy.Reporting", + "mapsTo": "Reporting", + "rules": ["naming"], + "reason": "The project name predates the catalogue and a published pipeline references it.", + "reviewedOn": "2026-10-18" + } +] +``` + +- `mapsTo` keeps every dependency rule in force; only the name is excused. +- Excuse one rule, not the whole set. +- Set `waiverPolicy.maxAgeDays` so a waiver must be renewed. An unreviewed waiver is how a policy + quietly stops being true. + +`SkipRule` removes a rule for the whole repository with no record. Treat it as a last resort, never as +the adoption strategy. + +## 5. Start the dependency budget empty + +`dependencyBudget` is a ratchet on the project graph. It records the references each project may +declare today, so a new dependency needs a reviewed policy change. + +```json +"dependencyBudget": { + "enabled": true, + "failOnStale": true, + "entries": [] +} +``` + +The rule is enabled and checks nothing, which is exactly right: the mechanism is in place. Add one +entry at a time, listing only the references a project has today. + +| Field | Default | Meaning | +| -- | -- | -- | +| `enabled` | `false` | Whether the budget is enforced. | +| `failOnStale` | `true` | Fail when an allowed reference no longer exists. | +| `entries[].project` | — | The production project the entry budgets. | +| `entries[].references` | — | The project references the budget permits. | + +- A reference outside the entry is a violation. +- With `failOnStale`, an allowance the project no longer uses is also a violation. Tighten the + budget: a stale allowance hides the next dependency. +- The rule is **not waivable**; the budget is the record that keeps growth reviewable. +- A project with no entry is not budgeted, so adoption can proceed one project at a time. + +## 6. Tighten one step at a time + +Each step is a small pull request that keeps the suite green: + +1. **Naming.** Add aliases or waivers, then rename projects in a dedicated change. +2. **Forbidden packages.** Add one prefix to one responsibility, fix, repeat. The rule checks the + packages the project owns: what it declares, what a shared props file injected, and what its own + package references pull in. Packages inherited from a **permitted** project reference are not the + project’s choice and are not reported; use `forbiddenPackageScope: "closure"` when the strict + compile-surface guarantee is what you want. +3. **Dependency direction.** Move one edge from `mayDependOn` to `mustNotDependOn`, or remove it from + `mayDependOn` so silence becomes a violation. +4. **Type rules.** Enable `typeRules` for one category with `sealConcreteClasses` and generated-code + exclusions, fix, then widen the scope. +5. **Ports.** Move an interface into `domain` or `application` only when an adapter implements it and + its signature is technology-neutral. +6. **Reachability.** Turn on `policyIntegrity.checkReachability` once the matrix is honest; it finds + prohibitions that a permitted path bypasses. + +A rule that cannot pass even with waivers is telling you the policy is wrong, not the code. Change +the policy. + +## Reading a failure during migration + +- `naming` — add an alias for an established name, or a waiver with `mapsTo`. +- `dependencyMatrix` / `assemblyDependencyMatrix` — the message names the reference, the direction + and the likely fix. Prefer moving a type over widening `mayDependOn`. +- `apiSurface` — expected after an intentional API change: run `architests api capture` and review the + diff before committing. +- `dependencyBudget` — a new reference is over budget; a stale allowance must be removed. +- `policyIntegrity` — the policy contradicts itself; fix the policy before the code. + +## In the sample + +[`ArchitectureSample`](https://github.com/purview-dev/architests/tree/main/src/samples/ArchitectureSample) +demonstrates the end state: a permissive-but-honest matrix, a committed API baseline, a narrow `naming` +waiver for `Legacy.Reporting`, and a dependency budget with one entry per production project. diff --git a/docs/Policy-Reference.md b/docs/Policy-Reference.md index 49e5129..72bcd6d 100644 --- a/docs/Policy-Reference.md +++ b/docs/Policy-Reference.md @@ -11,15 +11,26 @@ The authoritative definition is | Field | Type | Default | Meaning | | -- | -- | -- | -- | | `$schema` | string | — | Pointer to the published schema, so an editor validates as you type. | -| `version` | integer ≥ 1 | — | The document version. The current version is **2**. | +| `version` | integer ≥ 1 | — | The document version. The current version is **3**. | | `description` | string | — | Free text. | | `conventions` | object | — | `qualifiedNameSeparator` and `dependencyArrowMeaning`, for humans and editors. | | `sourceRoots` | string[] | `["src"]` | Repo-relative folders holding the projects. An empty array scans the repository root. | | `responsibilities` | object[] | — | **Required**, at least one. The responsibility catalogue. | | `discouragedProjectNames` | object | — | Names that describe a category, with a `reason`. | | `assemblyRules` | object | — | The type-level check. Off unless `enabled` is true. | +| `typeRules` | object | — | Type naming, shape, namespace and attribute rules. Off unless `enabled` is true. | +| `apiRules` | object | — | API baseline compatibility and XML documentation. Off unless `enabled` is true. | +| `portRules` | object | — | Port implementation and technology purity. Off unless `enabled` is true. | +| `dependencyBudget` | object | — | A ratchet on each project's declared references. Off unless `enabled` is true. | +| `policyIntegrity` | object | — | The policy's own consistency checks. `checkReachability` is off by default. | +| `waiverPolicy` | object | — | How long a waiver may go unreviewed. `maxAgeDays` is unset by default. | | `waivers` | object[] | `[]` | Deliberate, reviewed departures. | +### `version` 3 + +Version 3 adds `typeRules`, `policyIntegrity` and `waiverPolicy`. All are optional, so a version-2 +policy keeps passing unchanged; a repository adopts the new checks by adding the section it wants. + ### `version` 2 Version 2 removed the `rules` object (`noCycles`, `noProductionToTestReferences`, @@ -38,6 +49,7 @@ that set `rules` must delete the block; a version-1 policy that did not still va | `mayDependOn` | string[] | ✅ | Responsibilities this one may reference. An empty array means it depends on nothing. | | `mustNotDependOn` | string[] | ✅ | Responsibilities this one must never reference. | | `forbiddenPackagePrefixes` | string[] | | NuGet identifier prefixes that must not appear, e.g. `Microsoft.EntityFrameworkCore`. | +| `forbiddenPackageScope` | string | `"own"` | `"own"` checks only the packages the project owns; `"closure"` also checks packages inherited from project references. | | `forbiddenFrameworkReferences` | string[] | | Framework reference prefixes that must not appear, e.g. `Microsoft.AspNetCore.App`. | | `siblingIndependence` | boolean | | When true, qualified implementations of this responsibility must not reference one another. | | `separateProjectWhen` | string ≥ 10 | | Documentation only. | @@ -51,6 +63,270 @@ that set `rules` must delete the block; a version-1 policy that did not still va | `assemblyRoots` | string[] | `[]` | Repo-relative folders to scan. An empty array scans the test output directory. | | `assemblyGlobs` | string[] | `["*.dll"]` | File globs matched under each root. | +### Package resolution + +`forbiddenPackage` asks a narrower question than “is this package anywhere in the restore graph?”: +**does this project own it?** + +A host that references a persistence adapter inherits the adapter’s EF Core packages in its restore +closure. That is how NuGet works, but the host did not choose EF Core — it chose the adapter, which +the dependency matrix permits. Reporting the host for EF Core would make +`mayDependOn: ["Persistence"]` self-contradictory. + +Ownership is computed per target framework from `obj/project.assets.json`: + +```text +direct = packages the project declares, including one injected by Directory.Build.props +owned = the closure of direct, plus any resolved package no permitted reference explains +explained = packages reachable through ProjectReferences the matrix permits +``` + +- A package the project declares directly stays owned even when a referenced project also brings it. +- A package pulled in by one of the project’s own package references is owned. +- A package that only arrives through a **permitted** project reference is not owned, so a host is + not reported for its adapter’s technology. +- A package that arrives through a **disallowed** reference is not excused (the matrix already + reports the reference). +- The rule reports one finding per forbidden prefix, naming the shortest matching package and up to + three related packages — not one finding per `Microsoft.EntityFrameworkCore.*` assembly. +- An incomplete restore (no assets file, or a permitted reference missing from it) falls back to the + packages the project owns outright, so a partial restore cannot create false positives. +- Before any restore, only the declared `PackageReference` elements are checked. + +Set `forbiddenPackageScope` on the responsibility to `"closure"` when the policy really does mean +“this technology must not be anywhere on the compile graph, even inherited”: + +```json +{ + "name": "Api", + "category": "host", + "purpose": "The HTTP host that composes the application.", + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore"], + "forbiddenPackageScope": "closure" +} +``` + +Under `closure`, the host in the example above is reported. Use it deliberately: it usually means +the adapter’s package references need `PrivateAssets="compile"`, or the host must not reference the +adapter at all. + +The default is `"own"`. + +## Type rules + +`typeRules` reads each project's compiled assembly by metadata only — nothing is loaded or executed — +and polices the **public** surface it declares. Internal types are not checked: they are not part of +the API a consumer can call. The group is off by default because it needs a build. + +| Field | Type | Default | Meaning | +| -- | -- | -- | -- | +| `enabled` | boolean | `false` | Whether the type-level rules run. | +| `appliesTo.categories` | string[] | `[]` | Categories to govern. Empty governs every production responsibility. | +| `excludeGeneratedCode` | boolean | `true` | Skip `[GeneratedCode]` and `[CompilerGenerated]` types. | +| `excludeNamespacePrefixes` | string[] | `[]` | Namespace prefixes to skip, such as a generated client. | +| `discouragedTypeSuffixes` | string[] | `["Manager", "Helper", "Util", "Impl"]` | Suffixes that name a mechanism rather than a responsibility. | +| `sealConcreteClasses` | boolean | `true` | Require a public concrete class to be sealed. | +| `requireReadOnlyCollections` | boolean | `true` | Reject `List` and `Dictionary` in a public property or return type. | +| `forbiddenAttributes` | string[] | `[]` | Attributes no governed public type may carry. | +| `requiredAttributes` | string[] | `[]` | Attributes every governed public type must carry. | +| `allowObsoleteWithoutMessage` | boolean | `false` | Allow `[Obsolete]` with no replacement message. | +| `forbidInternalsVisibleToProduction` | boolean | `true` | Reject `InternalsVisibleTo` naming a production assembly. | + +```json +"typeRules": { + "enabled": true, + "appliesTo": { "categories": ["domain", "application", "shared"] }, + "excludeGeneratedCode": true +} +``` + +### Type naming + +`typeNaming` reports a public interface that does not start with `I`, an attribute class that does not +end with `Attribute`, an exception class that does not end with `Exception`, and a type ending with a +configured discouraged suffix. The suffix list targets names that describe a mechanism — `Manager`, +`Helper`, `Util`, `Impl` — rather than the responsibility the type has. + +### Type shape + +`typeShape` reports: + +- a public concrete class that is neither sealed nor abstract — an undeclared extension point; +- a public **instance** field, which cannot validate or evolve without a breaking change; +- a public property or method returning `List` or `Dictionary`, which hands the + caller the owner's mutable state; +- a class named `...Exception` that does not derive from `System.Exception`; +- a class with only static members and no public constructor that is not declared `static`. + +### Type namespace + +`typeNamespace` compares each public type's declared namespace with the project's `RootNamespace`, +read from the `.csproj`. A type must be in that namespace or a sub-namespace. Metadata records the +namespace but not the file path, so the rule deliberately makes no claim about folders. A project +that has not set `RootNamespace` falls back to its project name; set it explicitly if the namespace +differs. + +### Attribute policy + +`attributePolicy` applies the policy's attribute lists to public types and reports: + +- a type missing one of `requiredAttributes`; +- a type carrying one of `forbiddenAttributes`; +- `[Obsolete]` without a message on a public type or member, unless + `allowObsoleteWithoutMessage` is true — an obsolete member with no replacement is a dead end; +- an assembly-level `InternalsVisibleTo` that names a **production** assembly. Exposing internals to + a test project remains ordinary; exposing them to production code is a boundary leak. + +## API rules + +`apiRules` compares the public API a build produced with a committed baseline. The baseline is the +reviewed contract, so growing the API is as deliberate as shrinking it. Off by default because it +needs a build. + +| Field | Type | Default | Meaning | +| -- | -- | -- | -- | +| `enabled` | boolean | `false` | Whether the API rules run. | +| `baseline` | string | `.config/api-surface.json` | The baseline path relative to the repository root. | +| `requireTypeDocumentation` | boolean | `true` | Require every public type to have an XML documentation entry. | +| `requireMemberDocumentation` | boolean | `false` | Require every public member to have one too. | + +```json +"apiRules": { + "enabled": true +} +``` + +### API surface + +`apiSurface` reports every difference from the baseline: an added type or member, a removed one, or +a changed shape (kind, sealed, abstract, base type, interfaces, member type or static). A removal is +called out as breaking in the message. + +The baseline is written by the tool, never by hand: + +```bash +architests api capture # write .config/api-surface.json +architests api check # compare, exit 1 on any difference +``` + +The document is canonical: projects, types, interfaces and members are sorted, there are no +timestamps, and line endings are always `\n`, so an unchanged API regenerates byte for byte. Commit +it with the change that alters the API, exactly as you would commit a public API review. + +A missing baseline is itself an `apiSurface` failure, because the rule would otherwise pass without +checking anything. + +### API documentation + +`apiDocumentation` reads the XML documentation file the compiler writes beside each assembly. Every +public type must have a ``; with `requireMemberDocumentation`, every public member must too. +A project that ships no XML file is reported once, with the fix: set +`true`. + +Member documentation is off by default because a record's positional parameters document its +properties through the type's entry, so requiring a separate entry for every generated property +would penalise a correct record. + +## Port rules + +`portRules` treats interfaces in the inner layers as ports: contracts an outer layer implements. +Off by default because it reads compiled assemblies. + +| Field | Type | Default | Meaning | +| -- | -- | -- | -- | +| `enabled` | boolean | `false` | Whether the port rules run. | +| `categories` | string[] | `["domain", "application"]` | The categories whose interfaces are ports. | +| `forbiddenTypePrefixes` | string[] | common infrastructure prefixes | Types a port's signature must not name. | + +### Port implementation + +`portImplementation` reports an interface in a port category with **no public implementation** +anywhere in the repository. An unimplemented contract is either dead or a boundary nobody crossed; +both are worth a review. A port implemented in any production project passes — the dependency matrix +still governs where that implementation may live. + +### Port purity + +`portPurity` reports a port whose property, method return, or parameter names a forbidden prefix, +such as `Microsoft.EntityFrameworkCore`, `System.Data`, `Microsoft.AspNetCore` or `System.Net.Http`. +A port that names a database or an HTTP client is not a boundary; it is a leak. Generic arguments +are part of the signature, so `IReadOnlyList` is caught too. + +## Dependency budget + +`dependencyBudget` is a ratchet on the project graph. It records the references each project is +allowed to declare today, so a new dependency cannot arrive without a reviewed policy change. + +| Field | Type | Default | Meaning | +| -- | -- | -- | -- | +| `enabled` | boolean | `false` | Whether the budget is enforced. | +| `failOnStale` | boolean | `true` | Fail when an allowed reference no longer exists. | +| `entries[].project` | string | — | The production project the entry budgets. | +| `entries[].references` | string[] | — | The project references the budget permits. | + +```json +"dependencyBudget": { + "enabled": true, + "failOnStale": true, + "entries": [ + { "project": "Application", "references": ["Domain"] }, + { "project": "Persistence", "references": ["Domain", "Application"] } + ] +} +``` + +The rule is **not waivable**: the budget is the record that keeps growth reviewable, and excusing it +would make the policy unfalsifiable. + +- A reference not in the entry is a violation. Remove it, or add it in a reviewed change. +- With `failOnStale`, an allowance the project no longer uses is also a violation. Tighten the + budget: a stale allowance hides the next dependency. +- Each production project has at most one entry. A project with no entry is not budgeted, so a + repository can adopt the ratchet one project at a time. +- An empty `entries` array is valid and checks nothing, which is the recommended starting point: the + policy is enabled, and the first entry is added deliberately. + +## Policy integrity + +`policyIntegrity` always runs, and is not waivable: a waiver excusing a broken policy would make the +policy unfalsifiable. It reports what a JSON Schema cannot express. + +| Check | Reported | +| -- | -- | +| Unknown name | A `mayDependOn` or `mustNotDependOn` entry names no responsibility. Aliases and project names are not accepted in the matrix. | +| Overlap | The same responsibility appears in both lists for one responsibility. | +| Duplicate name | Two responsibilities share a name, so only the first can classify a project. | +| Alias collision | An alias equals a responsibility name, or two responsibilities declare the same alias. | +| Reachability (opt-in) | The permitted `mayDependOn` closure reaches a responsibility the policy forbids, so the prohibition cannot be enforced. | + +```json +"policyIntegrity": { + "checkReachability": true +} +``` + +The reachability failure names the path, for example `Core -> Application -> Persistence`, so the +edge that closes it is obvious. It is opt-in because a matrix can be perfectly consistent while a +longer permitted path still defeats a prohibition — a stronger claim, and a deliberate one. + +## Waiver expiry + +`waiverPolicy.maxAgeDays` gives `reviewedOn` a deadline: + +```json +"waiverPolicy": { + "maxAgeDays": 180 +} +``` + +A waiver reviewed longer ago than the maximum is a `waiverIntegrity` failure. A waiver with no +`reviewedOn`, a malformed date, or a date in the future is also reported: an undated waiver cannot +expire, and a review cannot predate the departure it records. Expiry is off by default, so adopting +the maximum is a deliberate policy change rather than a breaking upgrade. + +`architests waivers list --stale` applies the same review and exits non-zero when any waiver is due, +so the check also works in CI without a test run. + ## Waivers | Field | Type | Required | Meaning | @@ -59,7 +335,7 @@ that set `rules` must delete the block; a version-1 policy that did not still va | `mapsTo` | string | | The responsibility the project actually has. Keeps every dependency rule in force while excusing the name. | | `rules` | string[] | ✅ | The rule ids this waiver excuses. At least one. | | `reason` | string ≥ 40 | ✅ | Why the departure is acceptable. | -| `reviewedOn` | date | | When the departure was last reviewed. | +| `reviewedOn` | date | | When the departure was last reviewed, as `yyyy-MM-dd`. Checked against `waiverPolicy.maxAgeDays` when that is set. | Any rule id may be waived, including `siblingIndependence` and `assemblyDependencyMatrix`. An unknown id is a `waiverIntegrity` failure, so a typo cannot silently excuse nothing. @@ -68,14 +344,24 @@ id is a `waiverIntegrity` failure, so a typo cannot silently excuse nothing. | Rule id | Catches | | -- | -- | +| `policyIntegrity` | The policy names an unknown responsibility, permits and forbids the same dependency, or declares a duplicate name or alias. Not waivable. | | `graphDiscovery` | No production projects were discovered, so every other rule would pass vacuously. Not waivable. | | `naming` | A production project whose name maps to no responsibility or alias. | | `discouragedName` | A project named after a category, so nothing can be kept out of it. | | `dependencyMatrix` | A declared `ProjectReference` the matrix forbids or does not permit. | | `assemblyDependencyMatrix` | A type reference the matrix forbids or does not permit, including one that arrives transitively or through a package. | +| `typeNaming` | A public interface whose name lacks `I`, an attribute or exception without its suffix, or a discouraged suffix. | +| `typeShape` | An unsealed public class, a public instance field, a mutable collection return, or a utility class that is not static. | +| `typeNamespace` | A public type declared outside its project's root namespace. | +| `attributePolicy` | A required attribute missing, a forbidden one present, `[Obsolete]` without a message, or `InternalsVisibleTo` naming production code. | +| `apiSurface` | The public API differs from its committed baseline. | +| `apiDocumentation` | A public type or member has no XML documentation entry. | +| `portImplementation` | An interface in a port category has no public implementation. | +| `portPurity` | A port's signature exposes a forbidden infrastructure technology. | +| `dependencyBudget` | A project references beyond its budget, or the budget still allows a hook that has gone. Not waivable. | | `siblingIndependence` | A qualified implementation referencing a qualified sibling. | | `compositionBoundary` | A non-composition, non-host project referencing a project whose category is `composition`. | -| `forbiddenPackage` | A package matching a forbidden prefix for the responsibility. | +| `forbiddenPackage` | A package the responsibility owns matching a forbidden prefix. | | `forbiddenFramework` | A `` matching a forbidden prefix for the responsibility. | | `cycle` | A cycle in the project dependency graph, reported readably. | | `testReference` | A production project referencing a test project. | diff --git a/docs/Tutorial.md b/docs/Tutorial.md new file mode 100644 index 0000000..f349d8a --- /dev/null +++ b/docs/Tutorial.md @@ -0,0 +1,915 @@ +# Tutorial: grow a policy with the code + +[Getting started](Getting-Started.md) gives you the smallest working setup. This tutorial goes further: +it builds one small application and grows its policy alongside it, from a two-project dependency rule +to the full set of assembly, type, API, port and budget checks. + +Each stage: + +1. adds a structural idea to the code and the matching policy; +2. ends with a green suite; +3. deliberately breaks one rule so you can read the failure and fix the cause. + +Use a scratch repository, or follow along against +[`ArchitectureSample`](https://github.com/purview-dev/architests/tree/main/src/samples/ArchitectureSample). +The sample is the finished version of this tutorial, with the same project names and a complete +policy you can compare against. + +## Before you start + +You need a TUnit test project with: + +```bash +dotnet add package Purview.Architecture.Testing +``` + +and the one-line suite: + +```csharp +using Purview.Architecture.Testing; + +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +`[InheritsTests]` is required: TUnit does not walk base types during discovery, so without it the +class compiles, the run is green and nothing is enforced. The test project must reference every +production project, because the type-level rules read the compiled assemblies from its output. Add +each new production project to the test project as you create it; the later stages only see what is +in the test output. + +The layout used here is: + +```text +orders/ +├── .config/ +│ └── architecture-policy.json +├── src/ +│ ├── Domain/ +│ ├── Application/ +│ ├── Persistence.InMemory/ +│ ├── Persistence.Json/ +│ ├── Persistence.SqlServer/ +│ ├── Composition/ +│ └── Legacy.Reporting/ +└── tests/ + └── ArchitectureTests/ +``` + +The stages add these projects as they need them. Every project name starts with a responsibility +name; the part after the first dot is an implementation qualifier and does not create a separate +responsibility. + +## Stage 1: two projects and one direction + +Start with the domain and one use case. + +`src/Domain/Order.cs`: + +```csharp +namespace Domain; + +/// An order placed through the application. +/// The order identifier. +/// The order total. +public sealed record Order(Guid Id, decimal Total); +``` + +`src/Domain/IOrderRepository.cs`: + +```csharp +namespace Domain; + +/// The port through which orders are stored. +public interface IOrderRepository +{ + /// Stores an order. + /// The order to store. + void Save(Order order); + + /// Returns every stored order. + /// The stored orders. + IReadOnlyList All(); +} +``` + +`src/Application/PlaceOrderHandler.cs`: + +```csharp +using Domain; + +namespace Application; + +/// Coordinates placing an order over the domain's port. +/// Where placed orders are stored. +public sealed class PlaceOrderHandler(IOrderRepository repository) +{ + /// Places an order and stores it. + /// The order total. + /// The placed order. + public Order Place(decimal total) + { + Order order = new(Guid.NewGuid(), total); + repository.Save(order); + return order; + } +} +``` + +`Application.csproj` references `Domain`; `Domain.csproj` references nothing. + +### The policy + +Create `.config/architecture-policy.json`: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 3, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Domain", + "category": "domain", + "purpose": "The order model, its invariants and the storage port it owns.", + "mayDependOn": [], + "mustNotDependOn": ["Application"] + }, + { + "name": "Application", + "category": "application", + "purpose": "Coordinates the use cases over the domain's ports.", + "mayDependOn": ["Domain"], + "mustNotDependOn": [] + } + ] +} +``` + +What each field means here: + +| Field | Why it is there | +| -- | -- | +| `sourceRoots` | Where the project graph is discovered. An empty graph would satisfy every rule, which is why `graphDiscovery` fails instead of passing silently. | +| `name` | The responsibility's first name segment. `Application` classifies the `Application` project and any `Application.*` implementation. | +| `category` | What kind of box this is. It matters later for ports, composition and hosts. | +| `purpose` | Why the responsibility exists. It is quoted in failure messages and must be at least 20 characters. | +| `mayDependOn` | The **complete** set of permitted references. An empty array means "depends on nothing"; silence is not permission. | +| `mustNotDependOn` | Prohibitions worth naming. They must not overlap `mayDependOn`. | + +Run the suite: + +```bash +dotnet test +``` + +The default suite now checks the declared project graph: `policyIntegrity`, `graphDiscovery`, +`naming`, `dependencyMatrix`, `cycle`, `testReference`, `discouragedName`, `forbiddenPackage`, +`forbiddenFramework`, `compositionBoundary`, `siblingIndependence` and `waiverIntegrity`. The +package, assembly, type, API, port and budget groups are still off. + +### Break it: forget a permission + +The code is correct, but the policy can still be wrong. Change `Application`'s permission to an empty +list: + +```diff +- "mayDependOn": ["Domain"], ++ "mayDependOn": [], +``` + +`dependencyMatrix` now reports that `Application` references `Domain`, "which the policy neither +permits nor forbids", and tells you to add `Domain` to `mayDependOn`. That is the allowlist doing its +job: an unlisted edge is a failure, not an accidental allowance. Restore `["Domain"]`. + +`mustNotDependOn` is for the prohibitions worth naming. `Domain` could not reference `Application` +even without it, because `Domain.mayDependOn` is empty; adding the prohibition makes the intent +explicit and gives the stronger "must not depend" message. + +## Stage 2: an adapter, and a port in the middle + +The application depends on `IOrderRepository`, but nothing implements it yet. Add +`src/Persistence.InMemory` and reference `Domain` only. + +`src/Persistence.InMemory/InMemoryOrderRepository.cs`: + +```csharp +using Domain; + +namespace Persistence.InMemory; + +/// An in-memory adapter for the domain's storage port. +public sealed class InMemoryOrderRepository : IOrderRepository +{ + readonly List _orders = []; + + /// + public void Save(Order order) => _orders.Add(order); + + /// + public IReadOnlyList All() => _orders; +} +``` + +Add the responsibility and extend the two prohibitions: + +```json +{ + "name": "Persistence", + "category": "infrastructure", + "purpose": "Adapters that implement the domain's storage port.", + "mayDependOn": ["Domain"], + "mustNotDependOn": ["Application"] +} +``` + +```diff +- "mustNotDependOn": ["Application"] ++ "mustNotDependOn": ["Application", "Persistence"] +``` + +on `Domain`, and: + +```diff +- "mustNotDependOn": [] ++ "mustNotDependOn": ["Persistence"] +``` + +on `Application`. + +`Application` does **not** reference an adapter. It owns the use case and depends on the port; the +adapter depends on the port too, and on nothing above it. The dependency arrow points inward: + +```text +Application ──> Domain <── Persistence.InMemory +``` + +### Break it: let the adapter depend on the application + +Add this to `Persistence.InMemory.csproj`: + +```xml + +``` + +`dependencyMatrix` reports: + +```text +'Persistence.InMemory' (Persistence) references 'Application' (Application), but Persistence must +not depend on Application. Invert it: Application may depend on Persistence, or introduce an +abstraction that Persistence owns and Application implements. +``` + +Remove it. If you remove `Application` from `mustNotDependOn` but leave it out of `mayDependOn`, the +same rule reports that the policy "neither permits nor forbids" the edge. Both failures mean the same +thing: the policy is complete, so an unlisted edge is not accidentally allowed. + +## Stage 3: siblings and the composition root + +Add `src/Persistence.Json` as a second adapter for the same port, referencing `Domain` only: + +```csharp +using System.Text.Json; +using Domain; + +namespace Persistence.Json; + +/// A JSON adapter for the domain's storage port. +public sealed class JsonOrderRepository : IOrderRepository +{ + readonly List _documents = []; + + /// + public void Save(Order order) => _documents.Add(JsonSerializer.Serialize(order)); + + /// + public IReadOnlyList All() => + [.. _documents.Select(document => JsonSerializer.Deserialize(document)!)]; +} +``` + +Now add the composition root, which is allowed to know every layer: + +`src/Composition/CompositionRoot.cs`: + +```csharp +using Application; +using Domain; +using Persistence.InMemory; + +namespace Composition; + +/// Selects an adapter and wires the application handler. +public sealed class CompositionRoot +{ + readonly PlaceOrderHandler _handler; + + /// Wires the in-memory adapter into the handler. + public CompositionRoot() => _handler = new PlaceOrderHandler(new InMemoryOrderRepository()); + + /// Places an order through the wired adapter. + /// The order total. + /// The placed order. + public Order PlaceAndSave(decimal total) => _handler.Place(total); +} +``` + +`Composition.csproj` references `Domain`, `Application`, `Persistence.InMemory` and +`Persistence.Json`. + +Add the responsibility, and turn on sibling independence for the qualified adapters: + +```json +{ + "name": "Composition", + "category": "composition", + "purpose": "The composition root that selects an adapter and wires the handler.", + "mayDependOn": ["Domain", "Application", "Persistence"], + "mustNotDependOn": [] +} +``` + +```diff +- "mustNotDependOn": ["Application", "Persistence"] ++ "mustNotDependOn": ["Application", "Persistence", "Composition"] +``` + +on `Domain` gives the matrix an explicit prohibition for the new responsibility. + +```diff +- "mustNotDependOn": ["Persistence"] ++ "mustNotDependOn": ["Persistence", "Composition"] +``` + +on `Application`, and: + +```diff + "name": "Persistence", + "category": "infrastructure", + "purpose": "Adapters that implement the domain's storage port.", + "mayDependOn": ["Domain"], +- "mustNotDependOn": ["Application"] ++ "mustNotDependOn": ["Application", "Composition"], ++ "siblingIndependence": true +``` + +`category: "composition"` is what makes the composition boundary work. It is a category, not a +project name, so the rule still applies if the project is called `Host` or `Bootstrap`. + +### Break it: make one adapter depend on its sibling + +Add a reference from `Persistence.Json` to `Persistence.InMemory`: + +```xml + +``` + +`siblingIndependence` reports: + +```text +'Persistence.Json' references its sibling 'Persistence.InMemory'. Sibling Persistence +implementations must stay independent; move the shared part into the unqualified 'Persistence' +project or into an abstraction both can depend on. +``` + +Remove the reference. A shared helper belongs in the shared `Persistence` project (if one exists), not +in one adapter that the other reaches into. + +### The composition boundary + +`compositionBoundary` fires when a project outside the `composition` and `host` categories references +the composition root. We will exercise it with `Legacy.Reporting` in stage 7: that project does not +take part in composition, so adding the reference cannot create a cycle. Using an adapter here would +create one, and the build would fail before the rule could report it. + +## Stage 4: keep technology in the adapter + +The adapters are still technology-neutral. Add a real database adapter, `Persistence.SqlServer`, +referencing `Domain` and the EF Core SQL Server package: + +```xml + + + + +``` + +Use the version your repository already has, or omit `Version` if central package management supplies +it. The adapter is the only project that names EF Core: + +```csharp +using Domain; +using Microsoft.EntityFrameworkCore; + +namespace Persistence.SqlServer; + +/// An EF Core adapter for the domain's storage port. +/// The database context. +public sealed class SqlOrderRepository(DbContext context) : IOrderRepository +{ + /// + public void Save(Order order) + { + context.Set().Add(order); + context.SaveChanges(); + } + + /// + public IReadOnlyList All() => [.. context.Set()]; +} +``` + +Add `Persistence.SqlServer` to `Composition.csproj` as well. The composition root still selects the +in-memory adapter in this tutorial; the reference exists so you can see what happens to the package +graph. + +Now tell `Domain` and `Application` that this technology is not theirs: + +```jsonc +// Add both properties to the Domain and Application responsibilities. +"forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore"], +"forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"] +``` + +Add those two lines to both responsibility objects. `forbiddenPackage` defaults to +`forbiddenPackageScope: "own"`: + +- a package the project declares itself is owned; +- a package injected by `Directory.Build.props` is owned; +- a package its own package references pull in is owned; +- a package inherited through a **permitted** project reference is not. + +That last point is why `Composition` may reference `Persistence.SqlServer` without being blamed for +EF Core. It chose the adapter; the adapter chose the database technology. Set +`"forbiddenPackageScope": "closure"` only when the policy really means "this technology must not be +anywhere on the compile graph, even inherited". That stricter mode usually requires adapters to mark +implementation packages `PrivateAssets="compile"`. + +### Break it: declare EF Core in the domain + +Add the package to `Domain.csproj`: + +```xml + +``` + +`forbiddenPackage` reports: + +```text +'Domain' (Domain) declares or brings in package 'Microsoft.EntityFrameworkCore' through its own +package references, which matches the forbidden prefix 'Microsoft.EntityFrameworkCore'. No permitted +project reference explains it. +``` + +Remove it. The same failure appears if the package arrives transitively from one of `Domain`'s own +package references, or is injected by a shared props file: ownership is about the project's choices, +not only its `.csproj` lines. + +### Break it: take a framework reference in the application + +Add this to `Application.csproj`: + +```xml + + + +``` + +`forbiddenFramework` reports: + +```text +'Application' (Application) references framework 'Microsoft.AspNetCore.App', which matches the +forbidden prefix 'Microsoft.AspNetCore.App' for Application. Coordinates the use cases over the +domain's ports. Move the framework reference to the responsibility that owns that technology. +``` + +Remove it. A host may need the web framework; a portable use-case layer should not. + +## Stage 5: read what the compiler sees + +The project matrix only sees declared `ProjectReference`s. Enable the assembly reader and the type +rules: + +```jsonc +// Add these top-level properties beside "responsibilities". +"assemblyRules": { + "enabled": true, + "assemblyRoots": [] +}, +"typeRules": { + "enabled": true, + "appliesTo": { + "categories": ["domain", "application", "infrastructure", "shared"] + }, + "excludeGeneratedCode": true, + "sealConcreteClasses": true +} +``` + +An empty `assemblyRoots` scans the test output directory, which already contains every production +assembly the test project references. The reader uses metadata only: it never loads or executes an +assembly. `assemblyDependencyMatrix` now applies the **same** `mayDependOn` / `mustNotDependOn` +matrix to every `TypeReference`, so a type that arrives through another project is checked even when +the `.csproj` does not name the ultimate dependency. + +`appliesTo.categories` limits which categories are governed; `excludeGeneratedCode` skips +`[GeneratedCode]` and `[CompilerGenerated]` types; `sealConcreteClasses` is one of the shape checks. +The type rules inspect public declarations in the governed categories: + +| Rule | What this stage now enforces | +| -- | -- | +| `typeNaming` | Interfaces start with `I`; attributes and exceptions carry their suffixes; no `Manager`/`Helper`-style names | +| `typeShape` | Public concrete classes are sealed, fields are private, collection returns are read-only, utility classes are static | +| `typeNamespace` | A public type sits under its project's `RootNamespace` (the project name by default) | +| `attributePolicy` | `[Obsolete]` has a message, and required or forbidden attributes are honoured | + +### Break it: unseal the model + +Remove `sealed` from `Order`: + +```csharp +public record Order(Guid Id, decimal Total); +``` + +`typeShape` reports: + +```text +'Domain.Order' is a public, inheritable class. Seal it, or make it abstract if it is meant to be a +base type: an unsealed class is a public extension point nobody declared. +``` + +Put `sealed` back. A record is a class to the metadata reader, so the same rule applies. + +### Break it: name an interface like a class + +Add a public interface beside the port: + +```csharp +public interface OrderStore +{ +} +``` + +`typeNaming` reports: + +```text +'Domain.OrderStore' is an interface whose name does not start with 'I'. Rename it to 'IOrderStore' +so the kind is visible at every use. +``` + +Delete it. The rule is about making the kind visible at every use, not about a naming preference in +one file. + +### Break it: expose a mutable collection + +Change the port's `All` return type: + +```csharp +List All(); +``` + +and update the implementations. `typeShape` reports: + +```text +'Domain.IOrderRepository.All' exposes 'System.Collections.Generic.List'. Return +IReadOnlyList or IReadOnlyDictionary so a caller cannot mutate the owner's state. +``` + +Restore `IReadOnlyList`. The same rule catches a public `List` property on a class. If a +type is deliberately outside the governed namespace, fix the namespace or set `RootNamespace` +explicitly; silently excluding it is how a public API grows unwatched. + +### What the assembly rule adds + +Suppose `Application` references `Domain`, and `Domain` accidentally references something that in +turn exposes a persistence type. Even if `Domain.csproj` never names the persistence project, +`assemblyDependencyMatrix` sees the `TypeReference` to the persistence assembly and applies the +matrix. A package assembly is not attributed to a project, so it is ignored by this rule; package +policy belongs to `forbiddenPackage`. + +## Stage 6: contracts and ports + +The public API is now worth protecting. Enable the two contract groups: + +```jsonc +// Add these top-level properties beside "responsibilities". +"apiRules": { + "enabled": true +}, +"portRules": { + "enabled": true +} +``` + +`apiRules` reads the compiled assemblies and compares their public surface with +`.config/api-surface.json`. Additions and removals are both reported: the baseline is the reviewed +contract, so growing it is deliberate. A missing baseline is itself a failure, because an API rule +that silently checks nothing is worse than no rule. + +`portRules` treats interfaces in the `domain` and `application` categories as ports. It reports a port +with no public implementation, and a port whose signature names an infrastructure technology. The +default forbidden prefixes are `Microsoft.EntityFrameworkCore`, `System.Data`, `Microsoft.AspNetCore` +and `System.Net.Http`. + +Capture the baseline once, before enforcing it: + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli +architests api capture +git add .config/api-surface.json +``` + +Then run the suite. `apiDocumentation` requires XML documentation on every public type and member; +the code in this tutorial already has summaries, so the first run should be clean. If it lists +missing documentation, add the `` elements until it is green. + +### Break it: add an undocumented public type + +Add: + +```csharp +public sealed record OrderSummary(Guid Id, decimal Total); +``` + +`apiDocumentation` reports the new type. Add: + +```csharp +/// A read-only summary of an order. +/// The order identifier. +/// The order total. +``` + +and the rule is satisfied. The type is an API addition, so `apiSurface` will also report it until you +capture the baseline deliberately. + +### Break it: change the contract + +Add a property to `Order`: + +```csharp +public string Status { get; init; } = "Placed"; +``` + +`apiSurface` reports the addition. If the change is intended, run `architests api capture`, review the +diff in `.config/api-surface.json`, and commit it with the code. If it is not intended, revert it. +That is the whole point of the baseline: the API cannot change by accident. + +### Break it: add a port nobody implements + +Add a public interface to `Domain`: + +```csharp +/// Notifies a recipient that an order was placed. +public interface IOrderNotifier +{ + /// Notifies about an order. + /// The order. + void Notify(Order order); +} +``` + +`portImplementation` reports: + +```text +'Domain.IOrderNotifier' is a port in Domain with no public implementation in the repository. +Implement it in an adapter, or remove it: an unimplemented contract is a boundary nobody crossed. +``` + +Delete it. The rule asks whether the port is implemented anywhere in the repository; the dependency +matrix still decides where an implementation may live. The new interface is also a public API change, +so `apiSurface` reports it until you either remove it or capture a deliberate baseline. + +### Break it: leak a technology into the port + +Add this member to `IOrderRepository`: + +```csharp +System.Data.Common.DbConnection Connection { get; } +``` + +`portPurity` reports: + +```text +'Domain.IOrderRepository.Connection' exposes 'System.Data.Common.DbConnection', which names the +infrastructure prefix 'System.Data'. A port must be technology-neutral; move the technology into +the adapter. +``` + +Remove the member. A signature is part of the boundary: even a type the domain can reference without +a new package still tells every adapter what technology to use. + +## Stage 7: waivers and the ratchet + +Real repositories contain history. Add a reporting project whose name predates the catalogue: + +`src/Legacy.Reporting/Legacy.Reporting.csproj` references `Domain`, and +`OrderReport.cs` is a small read-only projection: + +```csharp +using Domain; + +namespace Legacy.Reporting; + +/// Read-only totals for the legacy report. +public sealed class OrderReport +{ + /// Returns the total value of the given orders. + /// The orders to total. + /// The sum of the order totals. + public decimal Total(IReadOnlyList orders) => orders.Sum(order => order.Total); +} +``` + +Add the responsibility it belongs to: + +```json +{ + "name": "Reporting", + "category": "shared", + "purpose": "Read-only projections over the domain for the legacy report.", + "mayDependOn": ["Domain"], + "mustNotDependOn": ["Application", "Persistence", "Composition"] +} +``` + +```diff +- "mustNotDependOn": ["Application", "Persistence", "Composition"] ++ "mustNotDependOn": ["Application", "Persistence", "Composition", "Reporting"] +``` + +on `Domain` keeps the prohibitions symmetrical. + +`naming` now fails for `Legacy.Reporting`, because its first segment is not a responsibility. Renaming +is the cleaner fix; when a published pipeline depends on the name, record a waiver instead: + +```jsonc +// Add these top-level properties beside "responsibilities". +"waiverPolicy": { + "maxAgeDays": 180 +}, +"waivers": [ + { + "project": "Legacy.Reporting", + "mapsTo": "Reporting", + "rules": ["naming"], + "reason": "The project name predates the catalogue and a published pipeline references it.", + "reviewedOn": "2026-10-18" + } +] +``` + +Use the date you actually review it; `maxAgeDays` makes an old waiver a `waiverIntegrity` failure. +`mapsTo` keeps every dependency rule in force: the waiver excuses only the name, and +`Legacy.Reporting` must still obey `Reporting`'s matrix. `SkipRule` would switch the rule off for the +whole repository and leave no record, so it is not the adoption tool. + +The new public type changes the API. Capture it deliberately: + +```bash +architests api capture +``` + +Review the diff and commit the baseline with the project. + +### Break it: reference the composition root + +Add a reference from `Legacy.Reporting` to `Composition`: + +```xml + +``` + +Two rules fire without creating a project cycle: + +- `dependencyMatrix` — `Reporting` has `Composition` in `mustNotDependOn`. +- `compositionBoundary` — `Composition` is the composition root, and only a `host` or another + `composition` project may reference it. + +```text +'Legacy.Reporting' references the composition root. Composition selects implementations, so it +depends on them - never the other way round, which would make the choice circular. +``` + +Remove the reference. The composition root is the one place that chooses an adapter; if a consumer +could call back into it, the choice would be circular. + +### Ratchet the project graph + +A dependency budget records the references each project may declare today. Enable it with one entry +per project: + +```jsonc +// Add this top-level property beside "responsibilities". +"dependencyBudget": { + "enabled": true, + "failOnStale": true, + "entries": [ + { "project": "Domain", "references": [] }, + { "project": "Application", "references": ["Domain"] }, + { "project": "Persistence.InMemory", "references": ["Domain"] }, + { "project": "Persistence.Json", "references": ["Domain"] }, + { "project": "Persistence.SqlServer", "references": ["Domain"] }, + { + "project": "Composition", + "references": [ + "Domain", + "Application", + "Persistence.InMemory", + "Persistence.Json", + "Persistence.SqlServer" + ] + }, + { "project": "Legacy.Reporting", "references": ["Domain"] } + ] +} +``` + +Turn on the reachability check once the matrix is honest: + +```jsonc +// Add this top-level property beside "responsibilities". +"policyIntegrity": { + "checkReachability": true +} +``` + +It reports a prohibition that a permitted path can bypass — for example, if `Domain` may depend on +`Application`, and `Application` may depend on `Persistence`, then a prohibition on `Domain` reaching +`Persistence` is not real. Fix the matrix, not the rule. + +### Break it: add an unbudgeted reference + +Add `Application` → `Persistence.InMemory`: + +```xml + +``` + +Two independent rules fail: + +- `dependencyMatrix` reports the forbidden edge. +- `dependencyBudget` reports that the reference is not in `Application`'s budget. + +Remove the reference. The budget catches a reference the matrix might permit but nobody reviewed; +the matrix catches a direction the architecture forbids. Both are reviewed policy changes. + +### Break it: leave a stale allowance + +Remove `Persistence.Json` from `Composition.csproj`. The project still compiles, but +`dependencyBudget` fails: + +```text +The dependency budget for 'Composition' still allows 'Persistence.Json', but the project no longer +references it. Tighten the budget: a stale allowance hides the next dependency. +``` + +Either restore the reference or remove the allowance in the same reviewed change. `dependencyBudget` +is deliberately not waivable: the budget is the record that keeps dependency growth reviewable. + +## Run the finished policy + +The suite is now the whole architecture review: + +```bash +dotnet test +``` + +Outside TUnit, the same policy can be checked from a terminal: + +```bash +architests validate # schema and policy-integrity checks +architests waivers list --stale # waivers that need review +architests api check # public API against the committed baseline +architests graph # the project graph +architests graph --format mermaid # the same graph for a diagram +``` + +`architests validate` does not run the project, assembly or type rules; `dotnet test` does. Keep both: +the test suite is the enforcement, and the CLI is the cheap check for hooks and CI steps. + +## What each stage added + +| Stage | Code change | Policy idea | Rules that start doing work | +| -- | -- | -- | -- | +| 1 | Domain + Application | A complete allowlist, with an explicit prohibition | `dependencyMatrix`, `naming`, `graphDiscovery` | +| 2 | An in-memory adapter | A port is implemented from the outside | `dependencyMatrix` in the other direction | +| 3 | A second adapter + composition root | Sibling independence and a composition category | `siblingIndependence`, `compositionBoundary` | +| 4 | An EF Core adapter | Technology belongs to the adapter, not the inner layers | `forbiddenPackage`, `forbiddenFramework` | +| 5 | Metadata reader + type rules | The compiled graph is the real dependency graph | `assemblyDependencyMatrix`, `typeNaming`, `typeShape`, `typeNamespace`, `attributePolicy` | +| 6 | API baseline + port rules | The public surface is a reviewed contract; a port stays neutral | `apiSurface`, `apiDocumentation`, `portImplementation`, `portPurity` | +| 7 | Legacy project + budget | Exceptions are recorded and reviewed; growth is a ratchet | `waiverIntegrity`, `dependencyBudget`, reachability | + +The order matters less than the habit: add one idea, make the suite green, then ratchet. A policy that +describes a perfect architecture on day one gets switched off; a policy that describes today's +architecture and tightens one edge at a time survives. + +## Where to go next + +- [The policy reference](Policy-Reference.md) — every field, default and rule id. +- [Type-level rules](Type-Level-Rules.md) — how the assembly reader attributes a type to a project. +- [API contracts](Contracts.md) — the baseline, XML documentation and the port rules. +- [Migration and the budget](Migration.md) — adopting this in a repository that already has history. +- [Command line](Cli.md) — `validate`, `api capture`, `waivers list` and `graph`. +- [Extending](Extending.md) — a bespoke rule when the catalogue does not fit. +- [The sample](https://github.com/purview-dev/architests/tree/main/src/samples/ArchitectureSample) — + the completed version of this application, with its full policy and baseline. diff --git a/docs/Type-Level-Rules.md b/docs/Type-Level-Rules.md index 6fe5456..fc03103 100644 --- a/docs/Type-Level-Rules.md +++ b/docs/Type-Level-Rules.md @@ -56,3 +56,21 @@ Enabled with no production assemblies found is a **failure**, not a silent pass At the type level, a qualified implementation collapses into its responsibility, exactly as it does at the project level: a type in `Persistence.SqlServer` referencing a type in `Persistence` is a same-responsibility reference and is allowed. + +## Naming and shape + +The same metadata reader also exposes the **declarations** an assembly contains, which powers the +opt-in type rules: + +| Rule | Checks | +| -- | -- | +| `typeNaming` | An interface without `I`, an attribute or exception without its suffix, a discouraged suffix | +| `typeShape` | An unsealed public class, a public instance field, a mutable collection return, a non-static utility class | +| `typeNamespace` | A public type outside the project's `RootNamespace` | +| `attributePolicy` | A missing or forbidden attribute, `[Obsolete]` without a message, `InternalsVisibleTo` naming production code | + +They share `assemblyRules.assemblyRoots` and `assemblyGlobs`, and are configured under +[`typeRules`](Policy-Reference.md#type-rules). The reader is metadata-only here too: it walks the +definition tables, never loading the assembly. A public type's file path is not in metadata, so +`typeNamespace` compares the declared namespace with `RootNamespace` and makes no claim about +folders. diff --git a/docs/index.md b/docs/index.md index 0a17a15..7141c28 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,9 +15,13 @@ public sealed class ArchitectureTests : PurviewArchitectureTests; ## Guides - [Getting started](Getting-Started.md) — adopt the package in a repository, step by step. +- [Tutorial](Tutorial.md) — grow one application and its policy from a two-project matrix to type, API, port and budget rules. - [Policy reference](Policy-Reference.md) — every field, every rule id, every default. - [Type-level rules](Type-Level-Rules.md) — how the assembly check works and how an assembly is attributed to a project. +- [API contracts](Contracts.md) — the committed baseline, XML documentation and the port rules. +- [Migration and budget](Migration.md) — adopt incrementally, waive narrowly and ratchet the graph. +- [Command line](Cli.md) — validate, capture the API, review waivers and render the graph. - [Extending](Extending.md) — add your own rule to the standard set. ## Reference diff --git a/global.json b/global.json index 2851e76..8b388e3 100644 --- a/global.json +++ b/global.json @@ -5,9 +5,9 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.7" + "Purview.BuildSdk": "1.1.0" }, "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index ef2ba1b..ba177c4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,8 +7,12 @@ docs_dir: docs nav: - Home: index.md - Getting started: Getting-Started.md + - Tutorial: Tutorial.md - Policy reference: Policy-Reference.md - Type-level rules: Type-Level-Rules.md + - API contracts: Contracts.md + - Migration and budget: Migration.md + - Command line: Cli.md - Extending: Extending.md theme: diff --git a/package.json b/package.json index 961a2d7..ebd217f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "architests", - "version": "1.1.1", + "version": "1.2.0", "license": "MIT", "author": { "name": "Kieron Lanning", @@ -14,4 +14,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/architests.git" } -} \ No newline at end of file +} diff --git a/skills/purview-architests-contracts/SKILL.md b/skills/purview-architests-contracts/SKILL.md new file mode 100644 index 0000000..3484534 --- /dev/null +++ b/skills/purview-architests-contracts/SKILL.md @@ -0,0 +1,163 @@ +--- +name: purview-architests-contracts +description: Use when enabling or debugging Purview.Architecture.Testing's committed-API and port rules - apiSurface, apiDocumentation, portImplementation and portPurity. Covers capturing and reviewing .config/api-surface.json, reading an added or breaking API change, requiring XML documentation, and keeping an inner-layer port implemented and technology-neutral. +license: MIT +metadata: + product: Purview.Architecture.Testing + repository: purview-dev/architests +--- + +# API contracts and ports + +Dependency rules say which project may reference which. These rules say what the public surface +actually **is**, and whether the interfaces that define a boundary are honest. They read the compiled +assemblies by metadata only and are off by default, because they need a build. + +| Rule | Group | Catches | +| -- | -- | -- | +| `apiSurface` | `apiRules` | The public API differs from the committed baseline | +| `apiDocumentation` | `apiRules` | A public type or member has no XML documentation | +| `portImplementation` | `portRules` | A port in an inner layer has no public implementation | +| `portPurity` | `portRules` | A port's signature names an infrastructure technology | + +```json +"apiRules": { + "enabled": true +}, +"portRules": { + "enabled": true +} +``` + +Each enabled rule becomes its own TUnit test. A disabled group produces no test, so an absent +`apiSurface` test means the group is off, not that the surface is unchecked. + +## The committed baseline + +`.config/api-surface.json` records every public type and member, grouped by project, with the +responsibility recorded for review context. It is the contract: a change to it is a change to what a +consumer may call. + +- **Captured, never hand-written.** `architests api capture` writes it. +- **Canonical.** Projects, types, interfaces and members are sorted; there are no timestamps; line + endings are always `\n`. An unchanged API regenerates byte for byte, so a diff means a real change. +- **Committed.** It is reviewed like code. A pull request that changes it explains why. + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli + +architests api capture # write the baseline from the current build +architests api check # compare; exit 1 on any difference +``` + +`api check` is the CI gate; `apiSurface` is the same comparison as a test. A missing baseline is +itself an `apiSurface` failure, because the rule would otherwise pass without checking anything. + +`api capture` and `api check` accept `--repository ` and `--baseline `. Unlike the test +run, a command scans the repository's own build output even when `assemblyRules.assemblyRoots` is +empty, because a tool does not run from a test output directory. + +## Reading a baseline failure + +The message names the project, the type or member, and whether it was added, removed or changed. + +- **Added.** The API grew. Run `architests api capture`, inspect the added entries, and commit them + with the feature. Additions are not breaking, but they are still a reviewed contract change. +- **Removed.** The message calls it breaking. Restore the member, or capture the baseline as a + deliberate breaking release and say so in the pull request. +- **Changed.** The shape moved: kind, `sealed`, `abstract`, base type, interfaces, a member's type, or + whether it is static. Treat the same way as a removal until proven otherwise. + +The baseline includes public **nested** types and the SDK's generated `Assembly` marker. +Both are real public metadata; leave them alone. + +## API documentation + +`apiDocumentation` reads the XML documentation file the compiler writes beside each assembly. + +- `requireTypeDocumentation` (default true) requires a `` entry for every public type. +- `requireMemberDocumentation` (default false) extends that to every public member. + +Member documentation is off by default because a record's positional parameters document its +properties through the type's entry; requiring a separate entry per generated property would punish +a correct record. + +A project that ships no XML file is reported once with the fix: set +`true`. The Purview SDK does this already. + +## The port rules + +A **port** is an interface in one of `portRules.categories`, which default to `domain` and +`application`. Those are the layers that own contracts; an interface in `shared` or in an adapter is +not a port by this definition. + +### `portImplementation` + +Every port must have at least one **public implementation** in a production project. An unimplemented +port is either dead or a boundary nobody crossed; both deserve a review. The dependency matrix still +governs where that implementation may live. + +A port implemented only by a consumer (an extension point such as `IArchitectureRule`) fits badly +here: either move it out of the port categories, or record a waiver explaining that the repository +is the contract's publisher, not its implementer. + +### `portPurity` + +A port's property types, return types and parameter types must not name a forbidden prefix. Defaults +cover `Microsoft.EntityFrameworkCore`, `System.Data`, `Microsoft.AspNetCore` and `System.Net.Http`. +Generic arguments count, so `IReadOnlyList` is caught. + +```json +"portRules": { + "enabled": true, + "forbiddenTypePrefixes": ["Microsoft.EntityFrameworkCore", "System.Data"] +} +``` + +A port that names a database or an HTTP client is not a boundary; it is a leak. Move the technology +into the adapter and keep the interface in terms the domain understands. + +## Waivers and scope + +All four rules are waivable per project and per rule, like the rest of the set: + +```json +"waivers": [ + { + "project": "Legacy.Api", + "rules": ["apiSurface"], + "reason": "The published surface is frozen for a major version; the next major captures it.", + "reviewedOn": "2026-10-18" + } +] +``` + +Prefer a narrower setting when the whole repository shares the exception: +`apiRules.requireMemberDocumentation: false`, a different `portRules.categories`, or a longer +`forbiddenTypePrefixes` list. + +## CI + +```yaml +- run: architests api check +``` + +`api check` needs a build but no test run. In the test suite, `apiSurface` does the same comparison, +so a repository that runs its architecture tests does not need the command — it is there for a fast +job and for reviewers who want the diff without running TUnit. + +## See it working + +`src/samples/ArchitectureSample` demonstrates all four rules: `Domain.IOrderRepository` is a port +implemented by `Persistence.InMemory` and `Persistence.Json`, `Application` depends only on the port, +and `.config/api-surface.json` is committed for all six projects. + +## Checklist + +- `apiRules.enabled` and `portRules.enabled` are deliberate. +- The baseline is committed, canonical, and regenerated after every intended API change. +- A removed or changed member is called out as breaking in the pull request. +- Every public type has a ``, and the project generates its XML documentation. +- Ports live in an inner layer, are implemented, and name no infrastructure technology. +- A waiver names one project and one rule, with a reason and a review date. + diff --git a/skills/purview-architests-migration/SKILL.md b/skills/purview-architests-migration/SKILL.md new file mode 100644 index 0000000..d2b5063 --- /dev/null +++ b/skills/purview-architests-migration/SKILL.md @@ -0,0 +1,143 @@ +--- +name: purview-architests-migration +description: Use when adopting Purview.Architecture.Testing in an existing repository that already has tangled dependencies, or when ratcheting an adopted policy tighter. Covers a permissive first policy, committing the API baseline before enforcing it, recording narrow waivers instead of using SkipRule, starting an empty dependency budget, and tightening one rule at a time without a big-bang rewrite. +license: MIT +metadata: + product: Purview.Architecture.Testing + repository: purview-dev/architests +--- + +# Migrating an existing repository + +A policy that describes the architecture you wish you had fails on day one and gets switched off. +Adopt the architecture you **do** have, then tighten it one reviewed step at a time. The rules are +the same either way; only the policy and the waivers change. + +## 1. Inventory before writing the policy + +- List every production project and what it references. `architests graph` prints the graph once the + policy can find the projects. +- Group projects into responsibilities that describe what they are for, not where they sit. A + qualified name (`Persistence.SqlServer`) belongs to `Persistence`; only the first segment counts. +- Name the composition root with `category: "composition"` and hosts with `category: "host"`, so the + composition boundary works without special cases. + +## 2. A permissive first policy + +Start with the full dependency closure permitted. The first run should fail only on things you are +ready to fix — usually naming. + +```json +{ + "version": 3, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Domain", + "category": "domain", + "purpose": "The domain model and its invariants.", + "mayDependOn": ["Application", "Persistence", "Composition"], + "mustNotDependOn": [] + } + ] +} +``` + +Set `sourceRoots` correctly first: an empty graph makes every rule pass vacuously, which is why +`graphDiscovery` fails instead. + +## 3. Add the test class once + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +`[InheritsTests]` is load-bearing. Without it the run is green and nothing is checked. + +## 4. Record the API before enforcing it + +If the public API is already shipped, capture it before enabling `apiRules`: + +```bash +architests api capture +``` + +Then enable the group. The first `apiSurface` run compares against the API you actually have, so it +passes, and every later change becomes a reviewed diff. Do the same for the sample or any package in +the repository. + +## 5. Waivers, not skips + +When a rule finds a real departure you cannot fix today, record it: + +```json +"waivers": [ + { + "project": "Legacy.Utilities", + "mapsTo": "Shared", + "rules": ["naming"], + "reason": "The package id is published; renaming it is a separate breaking release.", + "reviewedOn": "2026-10-18" + } +] +``` + +- `mapsTo` keeps every dependency rule in force; only the name is excused. +- Excuse one rule, not the whole set. +- Set `waiverPolicy.maxAgeDays` so a waiver has to be renewed. An unreviewed waiver is how a policy + quietly stops being true. + +`SkipRule` removes a rule for the whole repository with no record. Treat it as a last resort, never +as the adoption strategy. + +## 6. Start the dependency budget empty + +```json +"dependencyBudget": { + "enabled": true, + "failOnStale": true, + "entries": [] +} +``` + +The rule is enabled and checks nothing, which is exactly right: the mechanism is in place. Add an +entry for one project at a time, listing only the references it has today. From then on a new +reference needs a reviewed policy change, and a removed one must be removed from the budget. + +## 7. Tighten one step at a time + +Each step is a small pull request that keeps the suite green: + +1. **Naming.** Add aliases or waivers, then rename projects in a dedicated change. +2. **Forbidden packages.** Add one prefix to one responsibility, fix the violations, repeat. +3. **Dependency direction.** Move a single edge from `mayDependOn` to `mustNotDependOn`, or remove it + from `mayDependOn` entirely so silence becomes a violation. +4. **Type rules.** Enable `typeRules` for one category with `sealConcreteClasses` and generated-code + exclusions, fix, then widen the scope. +5. **Ports.** Move an interface into `domain` or `application` only when its implementation lives in + an adapter and its signature is technology-neutral. +6. **Reachability.** Turn on `policyIntegrity.checkReachability` once the matrix is honest; it will + find prohibitions the permitted closure bypasses. + +A rule that cannot pass even with waivers is telling you the policy is wrong, not the code. Change +the policy. + +## Reading a failure during migration + +- `naming` — add an alias for an established name, or a waiver with `mapsTo`. +- `dependencyMatrix` / `assemblyDependencyMatrix` — the message names the reference, the direction + and the likely fix. Prefer moving a type over widening `mayDependOn`. +- `apiSurface` — expected after any intentional API change: run `architests api capture` and review + the diff before committing. +- `dependencyBudget` — a new reference is over budget; a stale allowance needs removing. +- `policyIntegrity` — the policy contradicts itself; fix the policy before the code. + +## Checklist + +- `sourceRoots` finds the projects, and `graphDiscovery` passes. +- The matrix describes today's graph, not the target graph. +- The API baseline is committed before `apiRules` is switched on. +- Every departure is a narrow, dated waiver rather than a skip. +- The dependency budget starts empty and grows one reviewed entry at a time. +- Each tightening step is its own pull request, with the suite green. diff --git a/skills/purview-architests-policy-authoring/SKILL.md b/skills/purview-architests-policy-authoring/SKILL.md new file mode 100644 index 0000000..9f1f2f5 --- /dev/null +++ b/skills/purview-architests-policy-authoring/SKILL.md @@ -0,0 +1,190 @@ +--- +name: purview-architests-policy-authoring +description: Use when authoring or changing .config/architecture-policy.json for Purview.Architecture.Testing - defining responsibilities, categories, mayDependOn and mustNotDependOn, choosing sourceRoots, adding aliases, recording a waiver, setting waiver expiry, or fixing a policyIntegrity failure. Covers the version-3 document, the matrix semantics, and how to validate a policy without running the tests. +license: MIT +metadata: + product: Purview.Architecture.Testing + repository: purview-dev/architests +--- + +# Authoring an architecture policy + +The policy is the definition of the architecture. The tests only report what it says, so a vague or +contradictory policy produces a vague or contradictory suite. This skill is about getting the +document right; to *adopt* the package or read a failure, use `purview-architests` instead. + +## The shape + +`.config/architecture-policy.json` at the repository root: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 3, + "description": "What this repository's architecture is.", + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": [], + "mustNotDependOn": ["Application", "Persistence", "Composition"], + "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"] + } + ] +} +``` + +`version` and `responsibilities` are required. Everything else is optional. Version 3 adds +`policyIntegrity` and `waiverPolicy`; both are optional, so a version-2 document remains valid. + +## Responsibilities + +A project name is `[.]`, and only the **first segment** is +classified. `Persistence.SqlServer` and `Persistence` are both Persistence: qualification is a +naming choice, never a different set of rules. + +| Field | Guidance | +| -- | -- | +| `name` | One PascalCase segment. Name the role, not the technology. | +| `aliases` | Existing names you are not renaming. Prefer renaming and deleting the alias. | +| `category` | `domain`, `application`, `infrastructure`, `composition`, `host` or `shared`. `composition` is the exemption `compositionBoundary` keys on; `host` may also reference it. | +| `purpose` | At least 20 characters, and quoted in failures. Write why the responsibility exists; "misc" is not a purpose. | +| `mayDependOn` | The complete set of permitted references. Silence is not permission. | +| `mustNotDependOn` | Prohibitions that matter. Must not overlap `mayDependOn`. | +| `forbiddenPackagePrefixes` | NuGet prefixes that must never appear, such as `Microsoft.EntityFrameworkCore`. | +| `forbiddenPackageScope` | `own` (default) reports packages the project declares or its own packages pull in; `closure` also reports packages inherited from project references. | +| `forbiddenFrameworkReferences` | Framework prefixes, such as `Microsoft.AspNetCore.App` in a portable layer. | +| `siblingIndependence` | When true, qualified implementations of this responsibility must not reference each other. | + +Avoid names that describe a category. `Infrastructure`, `Services`, `Helpers`, `Common`, +`Utilities` and `Managers` are discouraged because nothing can be kept out of them. + +## The matrix is three-way + +For every declared reference, and for every type reference in the compiled assemblies: + +1. **Same responsibility** is always allowed. +2. **`mustNotDependOn`** is a violation. +3. **`mayDependOn`** is permitted. + +Anything else is a violation. That is deliberate: an unclassified dependency is a gap in the +policy, not an implicit yes. When a new edge is genuinely intended, add it to `mayDependOn` — that +is a reviewed policy change, which is the point. + +## Policy integrity + +`policyIntegrity` always runs and cannot be waived. It catches what the schema cannot: + +- a matrix entry that names no responsibility (aliases and project names are not names here); +- the same responsibility in both `mayDependOn` and `mustNotDependOn`; +- two responsibilities with the same name; +- an alias equal to a name, or two responsibilities claiming the same alias; +- with `checkReachability`, a prohibition the permitted dependencies can reach anyway. The failure + prints the path, such as `Core -> Application -> Persistence`, so the edge to remove is obvious. + +```json +"policyIntegrity": { + "checkReachability": true +} +``` + +Reachability is opt-in because it is the stronger claim: the lists can be consistent while a longer +permitted path still defeats the prohibition. + +## Waivers + +A waiver is a reviewed departure, not a skip: + +```json +"waivers": [ + { + "project": "Legacy.Utilities", + "mapsTo": "Shared", + "rules": ["naming"], + "reason": "Predates the catalogue; renaming breaks a published package identifier.", + "reviewedOn": "2026-10-18" + } +] +``` + +- `mapsTo` is the useful part: the project keeps its real responsibility, so every dependency rule + still applies and only the **name** is excused. +- Excuse the narrowest rule that applies. A waiver listing everything is a policy opt-out. +- `policyIntegrity`, `graphDiscovery` and `waiverIntegrity` cannot be waived; naming one is a + `waiverIntegrity` failure. + +### Expiry + +```json +"waiverPolicy": { + "maxAgeDays": 180 +} +``` + +A waiver older than the maximum is a `waiverIntegrity` failure. So are an undated waiver while a +maximum is set, a date that is not `yyyy-MM-dd`, and a date in the future. Expiry is off by default; +adopt it deliberately, and expect to renew or delete the waivers it surfaces. + +### Package scope + +`forbiddenPackagePrefixes` measures **ownership** by default: what the project declares, what a +shared props file injected, and what its own package references pull in. A package inherited through +a permitted project reference is not owned, so a host is not reported for its adapter’s EF Core. + +Use `"forbiddenPackageScope": "closure"` only when the policy means “this technology must not be +anywhere on the compile graph”. That makes hosts responsible for every package their adapters bring +in, and usually requires the adapter to mark implementation packages `PrivateAssets="compile"`. + +## Dependency budget + +`dependencyBudget` ratchets the project graph: it records the references each project may declare +today, so growth is a reviewed policy change. + +```json +"dependencyBudget": { + "enabled": true, + "failOnStale": true, + "entries": [ + { "project": "Application", "references": ["Domain"] }, + { "project": "Persistence.SqlServer", "references": ["Domain", "Application"] } + ] +} +``` + +- An entry lists **project names**, exactly as they appear on disk, including the qualification: + `Persistence.SqlServer`, not `Persistence`. +- A reference outside the entry is a failure. Remove it, or add it in the same reviewed commit that + adds the `ProjectReference`. +- With `failOnStale` (default true), an allowance the project no longer uses is a failure. Tighten the + budget rather than leaving a permission nobody needs. +- A project with no entry is not budgeted. Start with `entries: []`, then add one project at a time. +- The rule is not waivable; changing the budget is the sanctioned way to change the graph. + +## Validate the policy + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli +architests validate +architests waivers list --stale +``` + +`validate` runs the schema check and `policyIntegrity` without a build; `waivers list --stale` exits +non-zero when a waiver is due. Exit codes: `0` clean, `1` findings, `2` a malformed command line. +Both commands accept `--repository `. + +The test suite runs the same checks and one test per rule, so a policy that passes the tool can +still fail a matrix rule — that failure is an architecture decision, not a document error. + +## Checklist + +- Every responsibility has a purpose that says what it is for, not what it uses. +- Every dependency that exists is either permitted or forbidden; nothing is left unclassified. +- `mustNotDependOn` entries are not also in `mayDependOn`. +- Names are roles, not categories, and no alias duplicates a name. +- Waivers are narrow, justified, dated, and none is due. +- `policyIntegrity.checkReachability` is on before claiming a prohibition is enforced. +- A version bump is deliberate: adding optional sections does not require one. + diff --git a/skills/purview-architests-type-rules/SKILL.md b/skills/purview-architests-type-rules/SKILL.md new file mode 100644 index 0000000..1d93378 --- /dev/null +++ b/skills/purview-architests-type-rules/SKILL.md @@ -0,0 +1,188 @@ +--- +name: purview-architests-type-rules +description: Use when enabling or debugging Purview.Architecture.Testing's type-level rules - typeNaming, typeShape, typeNamespace and attributePolicy. Covers the typeRules policy section, which public types are governed, why a type is reported as unsealed or outside its root namespace, how generated code and nested types are treated, and how to fix or waive each finding. +license: MIT +metadata: + product: Purview.Architecture.Testing + repository: purview-dev/architests +--- + +# Type-level naming, shape and namespace rules + +These rules police the **public surface** a project compiles, not its dependencies. They read the +compiled assembly by metadata only: no assembly is loaded, no code runs, and no source file is +needed. Enable them when the API a consumer receives matters as much as the direction its +references run. + +For dependency direction, use the `purview-architests` skill. This skill covers `typeNaming`, +`typeShape`, `typeNamespace` and `attributePolicy`. + +## Enable them + +They are off by default, because they need a build to have produced the assemblies: + +```json +{ + "assemblyRules": { "enabled": true }, + "typeRules": { + "enabled": true, + "appliesTo": { "categories": ["domain", "application", "shared"] }, + "excludeGeneratedCode": true + } +} +``` + +- An empty `appliesTo.categories` governs **every production responsibility**. +- Test projects are never governed. +- `assemblyRules` and `typeRules` scan the same assemblies. `assemblyRoots` and `assemblyGlobs` live + under `assemblyRules` and apply to both; an empty `assemblyRoots` scans the test output directory. +- Only **public** types are checked, including public nested types. Internal and private types are + outside the API and are left alone. + +## What the four rules catch + +| Rule | Catches | +| -- | -- | +| `typeNaming` | An interface without `I`, an attribute without `Attribute`, an exception without `Exception`, a discouraged suffix (`Manager`, `Helper`, `Util`, `Impl`) | +| `typeShape` | An unsealed public class, a public instance field, a mutable collection return, a misnamed exception, a utility class that is not static | +| `typeNamespace` | A public type outside the project's `RootNamespace` | +| `attributePolicy` | A missing required attribute, a forbidden attribute, `[Obsolete]` without a message, `InternalsVisibleTo` naming production code | + +Each enabled rule becomes its own TUnit test. A disabled group produces **no** type-rule test rather +than a test that checks nothing, so a green run cannot mean "the group was off". + +## Governing a subset + +`appliesTo.categories` is the usual scope. A composition root is often excluded because it is allowed +to reference everything: + +```json +"appliesTo": { "categories": ["domain", "application", "infrastructure", "shared"] } +``` + +Generated code is skipped by default. A source generator must not be forced to change its output: + +```json +"excludeGeneratedCode": true, +"excludeNamespacePrefixes": ["Contoso.Generated"] +``` + +A type marked `[GeneratedCode]` or `[CompilerGenerated]` is skipped, as is any type under an excluded +namespace prefix. + +## Type naming + +- An interface name must start with `I` followed by an uppercase letter: `IOrderRepository`. +- A class deriving from `System.Attribute` must end with `Attribute`. +- A class deriving from an exception must end with `Exception`. +- A type ending with a configured `discouragedTypeSuffixes` entry is reported. The default list + names mechanisms, not responsibilities. Configure it to match the repository's vocabulary. + +Generic arity is removed before the check, so `IRepository` is checked as `IRepository`. + +## Type shape + +- **Unsealed public class.** A public concrete class must be `sealed` unless it is `abstract`. An + unsealed class is a public extension point nobody declared; seal it, or make the base type explicit. +- **Public instance field.** Constants and static readonly fields are fine; an instance field cannot + validate or evolve without a breaking change. +- **Mutable collection return.** A public property or method returning `List` or + `Dictionary` hands the caller the owner's state. Return `IReadOnlyList` or + `IReadOnlyDictionary`. +- **Misnamed exception.** A class named `...Exception` must derive from `System.Exception`. +- **Utility class.** A class with only static members and no public constructor must be declared + `static`, so it cannot be instantiated by accident. + +Turn off either check in the policy when it does not fit: + +```json +"sealConcreteClasses": false, +"requireReadOnlyCollections": false +``` + +## Type namespace + +The rule compares the type's declared namespace with the project's `RootNamespace`, read from the +`.csproj`: the namespace must equal it or start with it followed by a dot. + +Metadata records the namespace, not the file path, so the rule **cannot** check that the folder +matches. It deliberately makes no such claim. + +Set `RootNamespace` explicitly when it differs from the project name: + +```xml +Purview.Architecture.Testing +``` + +Without it, the project name is used, which is the SDK's own fallback. + +## Attribute policy + +| Setting | Default | Effect | +| -- | -- | -- | +| `requiredAttributes` | `[]` | Namespace-qualified attributes every governed public type must carry | +| `forbiddenAttributes` | `[]` | Namespace-qualified attributes no governed public type may carry | +| `allowObsoleteWithoutMessage` | `false` | Permit `[Obsolete]` with no message | +| `forbidInternalsVisibleToProduction` | `true` | Reject `InternalsVisibleTo` naming a production assembly | + +`[Obsolete("Use IOrderStore instead.")]` is fine. A bare `[Obsolete]` is reported: a caller is told +the type is going away but not what to do about it. + +`InternalsVisibleTo` is checked at assembly level. A friend that maps to a **test** project is +ordinary; a friend that maps to a **production** project is a boundary leak, because the referenced +project can now see internals it was never meant to depend on. Expose a public abstraction instead. + +## Fix or waive + +Fix the type when the rule is right; rename it, seal it, return a read-only interface, or move it +under the root namespace. + +A waiver is per project and per rule, and the project field is the **project name** the assembly +belongs to: + +```json +"waivers": [ + { + "project": "Persistence", + "rules": ["typeShape"], + "reason": "The EF entity must stay inheritable for the proxy; sealing it breaks materialisation.", + "reviewedOn": "2026-10-18" + } +] +``` + +Prefer the narrow settings when the whole repository shares an exception: `excludeNamespacePrefixes` +for generated code, `sealConcreteClasses` for a codebase that uses inheritance as a feature, or +`appliesTo.categories` to leave the composition root out. + +## Troubleshooting + +- **No type-rule tests at all.** `typeRules.enabled` is false, or the policy failed to load. The + group produces no test rather than a vacuous pass, so this is visible in the test list. +- **A rule reports nothing and you expected findings.** The projects were not built, or the + assembly was not in the scan root. Type rules use `assemblyRules.assemblyRoots`; an empty value + scans the test output directory, so the test project must reference the projects it governs. +- **A type you expected is missing.** It is internal, nested inside an internal type, marked as + generated, under an excluded namespace, in a test project, or in a responsibility outside + `appliesTo.categories`. +- **`typeNamespace` reports everything.** The project has no `RootNamespace` and its namespace is + not the project name. Set `RootNamespace` explicitly. +- **A public API baseline is also enabled.** Type-rule findings are API-shape findings; fix them + before regenerating a baseline, or the baseline records the problem. + +## See it working + +`src/samples/ArchitectureSample` enables `typeRules` for the `domain`, `application`, `infrastructure` +and `shared` categories. Its projects declare `RootNamespace` explicitly, its classes are sealed, its +collections are exposed as `IReadOnlyList`, and `IOrderRepository` follows the interface +convention. + +## Checklist + +- `typeRules.enabled` is true and the categories are deliberate. +- Every governed public class is sealed or abstract, unless `sealConcreteClasses` is off. +- No public instance fields or mutable collection returns are exposed. +- `RootNamespace` is set on projects whose namespace differs from their name. +- Generated code is excluded by attribute or namespace, not by loosening the rule. +- Every waiver names one project and one rule, with a reason and a review date. + diff --git a/skills/purview-architests/SKILL.md b/skills/purview-architests/SKILL.md index 754f567..dd9d5db 100644 --- a/skills/purview-architests/SKILL.md +++ b/skills/purview-architests/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-architests -description: Adopt or debug Purview.Architecture.Testing, the package that enforces a repository's project and type-level dependency policy as TUnit tests. Use when adding architecture tests to a repository, writing or changing .config/architecture-policy.json, enabling the assembly rule, recording a waiver, interpreting a dependency-matrix, type-level, cycle, sibling-independence, composition-boundary, forbidden-package, framework-reference or naming failure, or when the architecture tests pass but enforce nothing. +description: Adopt or debug Purview.Architecture.Testing, the package that enforces a repository's project and type-level dependency policy as TUnit tests. Use when adding architecture tests to a repository, writing or changing .config/architecture-policy.json, validating a policy with the architests tool, enabling the assembly, type, API or port rules, recording or expiring a waiver, setting a dependency budget, interpreting a policy-integrity, dependency-matrix, type-level, cycle, sibling-independence, composition-boundary, forbidden-package, framework-reference or naming failure, or when the architecture tests pass but enforce nothing. license: MIT metadata: product: Purview.Architecture.Testing @@ -18,7 +18,7 @@ The package defines the rules once so a policy is not copied into every reposito ```json { "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", - "version": 2, + "version": 3, "sourceRoots": ["src"], "responsibilities": [ { @@ -54,11 +54,21 @@ If the architecture tests "pass" but you cannot name which rule ran, check this | Rule id | Test | What it catches | | -- | -- | -- | +| `policyIntegrity` | `Policy_GivenItsOwnDefinitions_IsConsistent` | An unknown responsibility name, a dependency both permitted and forbidden, or a duplicate name or alias. Not waivable | | `graphDiscovery` | `Repository_GivenItsSourceRoots_YieldsProjectsToCheck` | An empty graph — which satisfies every other rule | | `naming` | `ProductionProjects_GivenTheirNames_MapToAResponsibility` | A project name matching no responsibility or alias | | `discouragedName` | `ProductionProjects_GivenTheirNames_AvoidCatchAllNames` | A project named after a category, so nothing can be kept out of it | | `dependencyMatrix` | `ProductionProjects_GivenTheirReferences_RespectTheDependencyMatrix` | A declared `ProjectReference` the matrix forbids or does not permit | | `assemblyDependencyMatrix` | `AssemblyTypes_GivenTheirReferences_RespectTheDependencyMatrix` | A **type** in one responsibility using a type in another, including a dependency that only arrives transitively or through a package | +| `typeNaming` | `TypeRules_GivenTheRepository_RespectThePolicy` | A public interface without `I`, an attribute or exception without its suffix, or a discouraged suffix | +| `typeShape` | `TypeRules_GivenTheRepository_RespectThePolicy` | An unsealed public class, a public field, a mutable collection return, or a non-static utility class | +| `typeNamespace` | `TypeRules_GivenTheRepository_RespectThePolicy` | A public type outside the project's root namespace | +| `attributePolicy` | `TypeRules_GivenTheRepository_RespectThePolicy` | A missing or forbidden attribute, `[Obsolete]` without a message, or `InternalsVisibleTo` naming production code | +| `apiSurface` | `APIRules_GivenTheRepository_RespectThePolicy` | The public API differs from the committed baseline | +| `apiDocumentation` | `APIRules_GivenTheRepository_RespectThePolicy` | A public type or member has no XML documentation | +| `portImplementation` | `PortRules_GivenTheRepository_RespectThePolicy` | A port in an inner layer has no implementation | +| `portPurity` | `PortRules_GivenTheRepository_RespectThePolicy` | A port exposes an infrastructure technology | +| `dependencyBudget` | `AdoptionRules_GivenTheRepository_RespectThePolicy` | A project references beyond its budget, or the budget still allows a reference that has gone | | `siblingIndependence` | `SiblingImplementations_GivenTheirReferences_DoNotDependOnEachOther` | A qualified implementation referencing a qualified sibling | | `compositionBoundary` | `Implementations_GivenTheirReferences_DoNotDependOnComposition` | A non-host project referencing the composition root (`category == "composition"`) | | `forbiddenPackage` | `ProductionProjects_GivenTheirPackages_AvoidForbiddenFrameworkDependencies` | A package matching a forbidden prefix for that responsibility | @@ -70,6 +80,39 @@ If the architecture tests "pass" but you cannot name which rule ran, check this A separate test validates the policy against the schema first, because a policy that does not match its schema may be silently missing a rule. +## Policy integrity + +`policyIntegrity` always runs and is **not waivable**, so a broken policy cannot be excused. It +reports what a JSON Schema cannot express: + +- a `mayDependOn` or `mustNotDependOn` entry that names no responsibility — aliases and project + names do not count in the matrix; +- the same responsibility in both lists, which is a contradiction rather than a choice; +- two responsibilities with the same name, or an alias that equals a name or another alias; +- with `checkReachability`, a `mustNotDependOn` the permitted `mayDependOn` closure can reach. The + message prints the path, such as `Core -> Application -> Persistence`. + +```json +"policyIntegrity": { + "checkReachability": true +} +``` + +Reachability is opt-in because it is a stronger claim: the direct lists can be consistent while a +longer permitted path still defeats the prohibition. + +## Check it without a test run + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli +architests validate +architests waivers list --stale +``` + +`validate` runs the schema check and `policyIntegrity`; it needs no build. Exit codes are `0` clean, +`1` findings, `2` a malformed command line. Both commands accept `--repository `; without it +the tool walks up from the current directory and stops at the repository boundary. + `graphDiscovery` runs before the rules it protects. If `sourceRoots` is wrong, discovery finds nothing and every other rule passes vacuously — that is what this rule exists to stop. @@ -102,6 +145,23 @@ catches what a project file cannot: a type that only arrives transitively, or th Assemblies are attributed to projects by the SDK's generated `Assembly` marker when a project's `AssemblyName` is unrelated to its name, and by name otherwise. +## Type naming, shape and namespace + +The same reader exposes the public declarations a project compiles, which powers four opt-in rules: +`typeNaming`, `typeShape`, `typeNamespace` and `attributePolicy`. They are configured under +`typeRules`, off by default, and share the assembly roots and globs above. + +```json +"typeRules": { + "enabled": true, + "appliesTo": { "categories": ["domain", "application", "shared"] } +} +``` + +Use the `purview-architests-type-rules` skill for what each rule catches, why a type is reported, and +how to fix or waive it. A disabled group produces no test, so a missing type-rule test is a policy +setting rather than a silent pass. + ## Classifying test and production projects The package decides which projects the rules govern from the project graph: @@ -111,6 +171,57 @@ The package decides which projects the rules govern from the project graph: design, so applying the matrix to it would make the rule set unusable. - Everything else is a **production project**, and every rule applies to it. +## API baseline and ports + +Four more rules are opt-in. `apiRules` compares the public API with a committed baseline and checks +its documentation; `portRules` checks that inner-layer interfaces are implemented and stay +technology-neutral. + +```json +"apiRules": { "enabled": true }, +"portRules": { "enabled": true } +``` + +Write the baseline with the tool, never by hand: + +```bash +architests api capture # write .config/api-surface.json +architests api check # exit 1 on any difference +``` + +A missing baseline is itself an `apiSurface` failure. A removal is reported as breaking; an addition +still needs a reviewed baseline change, because the baseline is the contract. The +`purview-architests-contracts` skill covers the baseline document, documentation requirements and +the port rules in detail. + +## Dependency budget + +`dependencyBudget` is a ratchet on the project graph: it records the references each project may +declare today, so a new dependency needs a reviewed policy change. + +```json +"dependencyBudget": { + "enabled": true, + "failOnStale": true, + "entries": [ + { "project": "Application", "references": ["Domain"] } + ] +} +``` + +- A reference outside the entry is a `dependencyBudget` failure. +- With `failOnStale` (the default), an allowance the project no longer uses fails too, so the budget + only tightens. +- It is **not waivable**, and a project without an entry is not budgeted. +- Start with `entries: []`, then add one project at a time. + +## See it working + +`src/samples/ArchitectureSample` is a six-project repository that exercises every rule group: a port +with two adapters, a waived legacy project, the type rules, the committed baseline and a dependency +budget. Use it as the reference when a policy question is easier to answer from a working example +than from prose. + ## Prefer a waiver to a skip ```csharp @@ -136,8 +247,22 @@ is itself checked by `waiverIntegrity`. `mapsTo` is the useful part: it maps an established project onto the responsibility it actually has, so the dependency rules still apply in full and only the *name* is excused. -Any rule id may be waived, including `siblingIndependence` and `assemblyDependencyMatrix`. An -unknown id is a `waiverIntegrity` failure, so a typo cannot silently excuse nothing. +Any waivable rule id may be waived, including `siblingIndependence` and `assemblyDependencyMatrix`. +An unknown id is a `waiverIntegrity` failure, so a typo cannot silently excuse nothing. Naming a +guard (`policyIntegrity`, `graphDiscovery` or `waiverIntegrity`) is also a failure: those rules exist +to keep the policy falsifiable, so a waiver that appears to excuse them would be a lie. + +### Waiver expiry + +```json +"waiverPolicy": { + "maxAgeDays": 180 +} +``` + +A waiver reviewed longer ago than the maximum is a `waiverIntegrity` failure. So are a waiver with +no `reviewedOn` while a maximum is set, a date that is not `yyyy-MM-dd`, and a date in the future. +Expiry is off by default; adopting it turns the review date from a note into an obligation. ## Adding your own rule @@ -177,6 +302,11 @@ assertion. The `.Because(...)` text lists every violation the rule found. waiver with `mapsTo`. - `dependencyMatrix` / `assemblyDependencyMatrix` — fix the reference, or move the type. Widening `mayDependOn` is the last resort, and it is a policy change that needs review. +- `forbiddenPackage` — by default the package is one the project **owns**: declared, injected by + `Directory.Build.props`, or pulled in by its own package references. A package inherited from a + permitted project reference is not reported, so a host is not blamed for its adapter’s EF Core. + Set `forbiddenPackageScope: "closure"` on the responsibility only when the strict compile-graph + guarantee is intended. - `forbiddenFramework` — a `` on a project that should not have it. Move it to the responsibility that owns that technology. - `graphDiscovery` — almost always a wrong `sourceRoots`. An empty array scans the repository root, @@ -216,7 +346,8 @@ else's rules. ## Policy schema version -The current policy document version is **2**. Version 2 removed the `rules` object +The current policy document version is **3**. Version 3 adds `typeRules`, `policyIntegrity` and +`waiverPolicy`, all optional, so a version-2 policy remains valid. Version 2 removed the `rules` object (`noCycles`, `noProductionToTestReferences`, `implementationsMustNotReferenceComposition`, `siblingImplementationsIndependent`), whose flags were deserialised but never consulted. Use `SkipRule` or a waiver to opt out of a rule. A version-1 policy that set `rules` must delete the diff --git a/src/ArchitectureTesting.slnx b/src/ArchitectureTesting.slnx index f88514a..4c3902d 100644 --- a/src/ArchitectureTesting.slnx +++ b/src/ArchitectureTesting.slnx @@ -11,16 +11,20 @@ + - + + + + diff --git a/src/samples/ArchitectureSample/.config/api-surface.json b/src/samples/ArchitectureSample/.config/api-surface.json new file mode 100644 index 0000000..8347e74 --- /dev/null +++ b/src/samples/ArchitectureSample/.config/api-surface.json @@ -0,0 +1,355 @@ +{ + "version": 1, + "projects": [ + { + "project": "Application", + "responsibility": "Application", + "types": [ + { + "name": "ApplicationAssembly", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [] + }, + { + "name": "Purview.Architecture.Application.PlaceOrderHandler", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Domain.IOrderRepository" + ] + }, + { + "kind": "Method", + "name": "Place", + "typeName": "Purview.Architecture.Domain.Order", + "isStatic": false, + "parameters": [ + "System.Decimal" + ] + } + ] + } + ] + }, + { + "project": "Composition", + "responsibility": "Composition", + "types": [ + { + "name": "CompositionAssembly", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [] + }, + { + "name": "Purview.Architecture.Composition.CompositionRoot", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "PlaceAndSave", + "typeName": "Purview.Architecture.Domain.Order", + "isStatic": false, + "parameters": [ + "System.Decimal" + ] + } + ] + } + ] + }, + { + "project": "Domain", + "responsibility": "Domain", + "types": [ + { + "name": "DomainAssembly", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [] + }, + { + "name": "Purview.Architecture.Domain.IOrderRepository", + "kind": "Interface", + "isAbstract": true, + "isSealed": false, + "interfaces": [], + "members": [ + { + "kind": "Method", + "name": "All", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Save", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Domain.Order" + ] + } + ] + }, + { + "name": "Purview.Architecture.Domain.Order", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "System.IEquatable" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.Guid", + "System.Decimal" + ] + }, + { + "kind": "Method", + "name": "$", + "typeName": "Purview.Architecture.Domain.Order", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Deconstruct", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "System.Guid&", + "System.Decimal&" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Domain.Order" + ] + }, + { + "kind": "Method", + "name": "Equals", + "typeName": "System.Boolean", + "isStatic": false, + "parameters": [ + "System.Object" + ] + }, + { + "kind": "Method", + "name": "GetHashCode", + "typeName": "System.Int32", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "ToString", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Id", + "typeName": "System.Guid", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Property", + "name": "Total", + "typeName": "System.Decimal", + "isStatic": false, + "parameters": [] + } + ] + } + ] + }, + { + "project": "Legacy.Reporting", + "responsibility": "Reporting", + "types": [ + { + "name": "LegacyReportingAssembly", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [] + }, + { + "name": "Purview.Architecture.Reporting.OrderReport", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Domain.IOrderRepository" + ] + }, + { + "kind": "Method", + "name": "Summarise", + "typeName": "System.String", + "isStatic": false, + "parameters": [] + } + ] + } + ] + }, + { + "project": "Persistence.InMemory", + "responsibility": "Persistence", + "types": [ + { + "name": "PersistenceInMemoryAssembly", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [] + }, + { + "name": "Purview.Architecture.Persistence.InMemory.InMemoryOrderRepository", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "Purview.Architecture.Domain.IOrderRepository" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "All", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Save", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Domain.Order" + ] + } + ] + } + ] + }, + { + "project": "Persistence.Json", + "responsibility": "Persistence", + "types": [ + { + "name": "PersistenceJsonAssembly", + "kind": "Class", + "isAbstract": true, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [], + "members": [] + }, + { + "name": "Purview.Architecture.Persistence.Json.JsonOrderRepository", + "kind": "Class", + "isAbstract": false, + "isSealed": true, + "baseType": "System.Object", + "interfaces": [ + "Purview.Architecture.Domain.IOrderRepository" + ], + "members": [ + { + "kind": "Constructor", + "name": ".ctor", + "typeName": "System.Void", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "All", + "typeName": "System.Collections.Generic.IReadOnlyList", + "isStatic": false, + "parameters": [] + }, + { + "kind": "Method", + "name": "Save", + "typeName": "System.Void", + "isStatic": false, + "parameters": [ + "Purview.Architecture.Domain.Order" + ] + } + ] + } + ] + } + ] +} diff --git a/src/samples/ArchitectureSample/.config/architecture-policy.json b/src/samples/ArchitectureSample/.config/architecture-policy.json index ebeb01f..386fd77 100644 --- a/src/samples/ArchitectureSample/.config/architecture-policy.json +++ b/src/samples/ArchitectureSample/.config/architecture-policy.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", - "version": 2, - "description": "The sample repository's architecture policy: a small layered application.", + "version": 3, + "description": "The sample repository's architecture policy: a small layered application that exercises every rule group.", "conventions": { "qualifiedNameSeparator": ".", "dependencyArrowMeaning": "A ProjectReference: the referencing project compiles against the referenced one." @@ -11,32 +11,42 @@ { "name": "Domain", "category": "domain", - "purpose": "The sample's domain model and its invariants.", + "purpose": "The sample's domain model, its invariants and the storage port it owns.", "mayDependOn": [], - "mustNotDependOn": ["Application", "Persistence", "Composition"], + "mustNotDependOn": ["Application", "Persistence", "Composition", "Reporting"], + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore"], "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"] }, { "name": "Application", "category": "application", - "purpose": "Coordinates the sample's use cases and workflows.", + "purpose": "Coordinates the sample's use cases over the domain's ports.", "mayDependOn": ["Domain"], "mustNotDependOn": ["Persistence", "Composition"], + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore"], "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"] }, { "name": "Persistence", "category": "infrastructure", - "purpose": "Durable storage for the sample's orders.", + "purpose": "Adapters that implement the domain's storage port.", "mayDependOn": ["Domain"], - "mustNotDependOn": ["Application", "Composition"] + "mustNotDependOn": ["Application", "Composition"], + "siblingIndependence": true }, { "name": "Composition", "category": "composition", - "purpose": "The sample's composition root, which selects and wires the implementations.", + "purpose": "The sample's composition root, which selects an adapter and wires the handler.", "mayDependOn": ["Domain", "Application", "Persistence"], "mustNotDependOn": [] + }, + { + "name": "Reporting", + "category": "shared", + "purpose": "Read-only projections over the domain that serve the sample's legacy report.", + "mayDependOn": ["Domain"], + "mustNotDependOn": ["Application", "Persistence", "Composition"] } ], "discouragedProjectNames": { @@ -44,6 +54,49 @@ "reason": "These name a category rather than a responsibility, so nothing can be kept out of them." }, "assemblyRules": { + "enabled": true, + "assemblyRoots": ["src"] + }, + "typeRules": { + "enabled": true, + "appliesTo": { + "categories": ["domain", "application", "infrastructure", "shared"] + }, + "excludeGeneratedCode": true + }, + "apiRules": { + "enabled": true + }, + "portRules": { "enabled": true - } + }, + "dependencyBudget": { + "enabled": true, + "failOnStale": true, + "entries": [ + { "project": "Application", "references": ["Domain"] }, + { "project": "Persistence.InMemory", "references": ["Domain"] }, + { "project": "Persistence.Json", "references": ["Domain"] }, + { + "project": "Composition", + "references": ["Domain", "Application", "Persistence.InMemory", "Persistence.Json"] + }, + { "project": "Legacy.Reporting", "references": ["Domain"] } + ] + }, + "policyIntegrity": { + "checkReachability": true + }, + "waiverPolicy": { + "maxAgeDays": 180 + }, + "waivers": [ + { + "project": "Legacy.Reporting", + "mapsTo": "Reporting", + "rules": ["naming"], + "reason": "The project name predates the catalogue and is referenced by a published pipeline; renaming it is a separate change.", + "reviewedOn": "2026-10-18" + } + ] } diff --git a/src/samples/ArchitectureSample/README.md b/src/samples/ArchitectureSample/README.md index fc5755f..18a08c7 100644 --- a/src/samples/ArchitectureSample/README.md +++ b/src/samples/ArchitectureSample/README.md @@ -1,18 +1,23 @@ # Architecture sample A small, fully working repository that adopts `Purview.Architecture.Testing`. It is a repository -inside the repository: it has its own `.config/architecture-policy.json`, its own source layout and -its own test project, so it demonstrates exactly what a consumer does. +inside the repository: its own `.config/architecture-policy.json`, its own layout and its own test +project, so it demonstrates exactly what a consumer does — and it exercises every rule group the +package ships. -``` +```text ArchitectureSample/ -├── .config/architecture-policy.json the policy — the single definition of the rules +├── .config/ +│ ├── architecture-policy.json the policy — the single definition of the rules +│ └── api-surface.json the committed public API baseline ├── src/ -│ ├── Domain/ the domain model, depends on nothing -│ ├── Application/ use cases, depends on Domain -│ ├── Persistence/ storage, depends on Domain -│ └── Composition/ the composition root, depends on all three -└── tests/Sample.ArchitectureTests/ one derived class; the whole suite +│ ├── Domain/ the model and IOrderRepository, the port +│ ├── Application/ PlaceOrderHandler, depends on the port only +│ ├── Persistence.InMemory/ one adapter for the port +│ ├── Persistence.Json/ a sibling adapter for the same port +│ ├── Composition/ selects an adapter and wires the handler +│ └── Legacy.Reporting/ a project named before the catalogue; waived by name +└── tests/Sample.ArchitectureTests/ one derived class; the whole suite ``` The entire suite is one line: @@ -25,15 +30,22 @@ public sealed class ArchitectureTests : PurviewArchitectureTests; `[InheritsTests]` is required: TUnit does not walk base types during discovery, so without it the class compiles, the run is green, and nothing is enforced. -## What the policy enforces here +## What the policy demonstrates + +| Group | Rules | What this sample shows | +| -- | -- | -- | +| Dependency direction | `dependencyMatrix`, `assemblyDependencyMatrix`, `siblingIndependence` | Domain depends on nothing; Application depends on Domain's port; the two Persistence adapters depend only on Domain and not on each other; Composition may depend on all of them | +| Boundaries | `compositionBoundary`, `testReference` | nothing references Composition; production code never references the test project | +| Packages and frameworks | `forbiddenPackage`, `forbiddenFramework` | Domain and Application forbid `Microsoft.EntityFrameworkCore` and `Microsoft.AspNetCore.App`; package checks read restore output and blame only the packages a project owns, not those inherited through a permitted project reference | +| Policy integrity | `policyIntegrity` (with `checkReachability`), `graphDiscovery`, `naming`, `discouragedName`, `cycle`, `waiverIntegrity` | the matrix is internally consistent and no permitted path bypasses a prohibition | +| Waivers | `naming` via `Legacy.Reporting` | the name is excused with `mapsTo: Reporting`; every dependency rule still applies to the project | +| Type rules | `typeNaming`, `typeShape`, `typeNamespace`, `attributePolicy` | interfaces are `I`-prefixed, classes are sealed, collections are read-only, every type sits under its project's `RootNamespace`, and no `[Obsolete]` lacks a message | +| Contracts | `apiSurface`, `apiDocumentation` | the public API matches `.config/api-surface.json`; every public type has XML documentation | +| Ports | `portImplementation`, `portPurity` | `IOrderRepository` has two public implementations and names no infrastructure technology | +| Adoption | `dependencyBudget` | one entry per production project; a new reference fails, and a stale allowance fails with `failOnStale` | -| Rule | What it checks in this sample | -| -- | -- | -| `dependencyMatrix` | `Domain` depends on nothing; `Application` and `Persistence` may depend on `Domain`; `Composition` may depend on all three | -| `assemblyDependencyMatrix` | the same direction at the **type** level, read from the compiled assemblies | -| `compositionBoundary` | only `Composition` (category `composition`) may be referenced by no one — nothing points back at it | -| `forbiddenFramework` | `Domain` and `Application` must not reference `Microsoft.AspNetCore.App` | -| `cycle`, `naming`, `discouragedName`, `testReference`, `waiverIntegrity` | the rest of the standard set | +The sample's six projects compile to six assemblies, so the type-level and contract readers have +real metadata to inspect. ## Run it @@ -41,6 +53,44 @@ class compiles, the run is green, and nothing is enforced. dotnet test src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj ``` -To see a rule fire, add a `ProjectReference` from `Domain` to `Persistence` in -`src/Domain/Domain.csproj` and run it again: `dependencyMatrix` fails and the message names the -direction and the fix. Remove it to go green. +With the tool installed, the same policy outside TUnit: + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli + +architests validate --repository src/samples/ArchitectureSample +architests api check --repository src/samples/ArchitectureSample +architests waivers list --repository src/samples/ArchitectureSample +architests graph --repository src/samples/ArchitectureSample +architests graph --format mermaid --repository src/samples/ArchitectureSample +``` + +## See a rule fire + +Each change below breaks exactly one rule; run the suite, read the message, then undo it. + +1. **Dependency direction.** Add `` + to `Domain.csproj`: `dependencyMatrix` reports the forbidden edge, and + `assemblyDependencyMatrix` may report the type-level use too. +2. **Type shape.** Remove `sealed` from `InMemoryOrderRepository`: `typeShape` reports an undeclared + public extension point. +3. **Port purity.** Add `Microsoft.EntityFrameworkCore.DbContext Context { get; }` to + `IOrderRepository`: `portPurity` reports the infrastructure leak. +4. **Documentation.** Delete the `` from `OrderReport`: `apiDocumentation` reports the + undocumented public type. +5. **API surface.** Add a public type to Domain: `apiSurface` reports it as an addition until you run + `architests api capture` and commit the baseline. +6. **Dependency budget.** Add a reference from Application to `Persistence.Json`: the budget reports + the reference as over budget. +7. **Waiver.** Remove the `Legacy.Reporting` waiver: `naming` reports the project name, because it + maps to no responsibility. + +## Regenerate the baseline + +After an intended API change: + +```bash +architests api capture --repository src/samples/ArchitectureSample +``` + +Commit `.config/api-surface.json` with the change and explain it in the pull request. diff --git a/src/samples/ArchitectureSample/src/Application/Application.csproj b/src/samples/ArchitectureSample/src/Application/Application.csproj index eeba661..34edef4 100644 --- a/src/samples/ArchitectureSample/src/Application/Application.csproj +++ b/src/samples/ArchitectureSample/src/Application/Application.csproj @@ -1,6 +1,7 @@ false + Purview.Architecture.Application diff --git a/src/samples/ArchitectureSample/src/Application/PlaceOrderHandler.cs b/src/samples/ArchitectureSample/src/Application/PlaceOrderHandler.cs index 1651396..494710f 100644 --- a/src/samples/ArchitectureSample/src/Application/PlaceOrderHandler.cs +++ b/src/samples/ArchitectureSample/src/Application/PlaceOrderHandler.cs @@ -2,11 +2,21 @@ namespace Purview.Architecture.Application; -/// Coordinates placing an order. Application may depend on the domain. -public sealed class PlaceOrderHandler +/// +/// Coordinates placing an order. Application depends on the domain's port, never on an adapter. +/// +/// Creates the handler over the domain's storage port. +/// Where placed orders are stored. +public sealed class PlaceOrderHandler(IOrderRepository repository) { - /// Places an order for the given total. + /// Places an order for the given total and stores it. /// The order total. /// The placed order. - public Order Place(decimal total) => new(Guid.NewGuid(), total); + public Order Place(decimal total) + { + Order order = new(Guid.NewGuid(), total); + repository.Save(order); + + return order; + } } diff --git a/src/samples/ArchitectureSample/src/Composition/Composition.csproj b/src/samples/ArchitectureSample/src/Composition/Composition.csproj index b1d3f53..b705b25 100644 --- a/src/samples/ArchitectureSample/src/Composition/Composition.csproj +++ b/src/samples/ArchitectureSample/src/Composition/Composition.csproj @@ -1,11 +1,13 @@ false + Purview.Architecture.Composition - + + diff --git a/src/samples/ArchitectureSample/src/Composition/CompositionRoot.cs b/src/samples/ArchitectureSample/src/Composition/CompositionRoot.cs index a6a3199..a179723 100644 --- a/src/samples/ArchitectureSample/src/Composition/CompositionRoot.cs +++ b/src/samples/ArchitectureSample/src/Composition/CompositionRoot.cs @@ -1,27 +1,22 @@ using Purview.Architecture.Application; -using Purview.Architecture.Persistence; +using Purview.Architecture.Domain; +using Purview.Architecture.Persistence.InMemory; namespace Purview.Architecture.Composition; /// -/// The composition root: the one place that selects and wires the concrete implementations. +/// The composition root: the one place that selects an adapter and wires the handler. Composition +/// may depend on every layer, and no layer may depend on it. /// public sealed class CompositionRoot { - /// The wired handler. - public PlaceOrderHandler Handler { get; } = new(); + readonly PlaceOrderHandler _handler; - /// The wired repository. - public OrderRepository Repository { get; } = new(); + /// Wires the in-memory adapter into the application handler. + public CompositionRoot() => _handler = new PlaceOrderHandler(new InMemoryOrderRepository()); - /// Places an order and saves it. + /// Places an order and stores it through the wired adapter. /// The order total. /// The placed order. - public Domain.Order PlaceAndSave(decimal total) - { - var order = Handler.Place(total); - Repository.Save(order); - - return order; - } + public Order PlaceAndSave(decimal total) => _handler.Place(total); } diff --git a/src/samples/ArchitectureSample/src/Domain/Domain.csproj b/src/samples/ArchitectureSample/src/Domain/Domain.csproj index 9a01564..416c0fd 100644 --- a/src/samples/ArchitectureSample/src/Domain/Domain.csproj +++ b/src/samples/ArchitectureSample/src/Domain/Domain.csproj @@ -2,5 +2,11 @@ false + + Purview.Architecture.Domain diff --git a/src/samples/ArchitectureSample/src/Domain/IOrderRepository.cs b/src/samples/ArchitectureSample/src/Domain/IOrderRepository.cs new file mode 100644 index 0000000..c7162ec --- /dev/null +++ b/src/samples/ArchitectureSample/src/Domain/IOrderRepository.cs @@ -0,0 +1,16 @@ +namespace Purview.Architecture.Domain; + +/// +/// The port through which orders are stored. The domain owns the contract, and an adapter outside +/// the domain implements it; the signature stays technology-neutral. +/// +public interface IOrderRepository +{ + /// Stores an order. + /// The order to store. + void Save(Order order); + + /// Returns every stored order. + /// The orders as a read-only list. + IReadOnlyList All(); +} diff --git a/src/samples/ArchitectureSample/src/Legacy.Reporting/Legacy.Reporting.csproj b/src/samples/ArchitectureSample/src/Legacy.Reporting/Legacy.Reporting.csproj new file mode 100644 index 0000000..3cc8188 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Legacy.Reporting/Legacy.Reporting.csproj @@ -0,0 +1,11 @@ + + + false + + Purview.Architecture.Reporting + + + + + + diff --git a/src/samples/ArchitectureSample/src/Legacy.Reporting/OrderReport.cs b/src/samples/ArchitectureSample/src/Legacy.Reporting/OrderReport.cs new file mode 100644 index 0000000..a20a1c4 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Legacy.Reporting/OrderReport.cs @@ -0,0 +1,18 @@ +using Purview.Architecture.Domain; + +namespace Purview.Architecture.Reporting; + +/// +/// Renders a plain-text report from the domain's storage port. The project is named +/// Legacy.Reporting; a narrow waiver maps the name onto the Reporting responsibility, so only +/// the name is excused and every dependency rule still applies. +/// +/// Creates the report over the domain's storage port. +/// The orders to report on. +public sealed class OrderReport(IOrderRepository repository) +{ + /// Renders one line per stored order. + /// The report text. + public string Summarise() => + string.Join(Environment.NewLine, repository.All().Select(order => $"{order.Id}: {order.Total}")); +} diff --git a/src/samples/ArchitectureSample/src/Persistence.InMemory/InMemoryOrderRepository.cs b/src/samples/ArchitectureSample/src/Persistence.InMemory/InMemoryOrderRepository.cs new file mode 100644 index 0000000..4b28983 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Persistence.InMemory/InMemoryOrderRepository.cs @@ -0,0 +1,18 @@ +using Purview.Architecture.Domain; + +namespace Purview.Architecture.Persistence.InMemory; + +/// +/// An in-memory adapter for the domain's storage port. It is one of two sibling implementations, +/// which is why the Persistence responsibility sets siblingIndependence. +/// +public sealed class InMemoryOrderRepository : IOrderRepository +{ + readonly List _orders = []; + + /// + public void Save(Order order) => _orders.Add(order); + + /// + public IReadOnlyList All() => _orders; +} diff --git a/src/samples/ArchitectureSample/src/Persistence.InMemory/Persistence.InMemory.csproj b/src/samples/ArchitectureSample/src/Persistence.InMemory/Persistence.InMemory.csproj new file mode 100644 index 0000000..76df040 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Persistence.InMemory/Persistence.InMemory.csproj @@ -0,0 +1,10 @@ + + + false + Purview.Architecture.Persistence.InMemory + + + + + + diff --git a/src/samples/ArchitectureSample/src/Persistence.Json/JsonOrderRepository.cs b/src/samples/ArchitectureSample/src/Persistence.Json/JsonOrderRepository.cs new file mode 100644 index 0000000..48416f6 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Persistence.Json/JsonOrderRepository.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using Purview.Architecture.Domain; + +namespace Purview.Architecture.Persistence.Json; + +/// +/// A JSON adapter for the domain's storage port, using the base class library's serializer. Sibling +/// implementations stay independent of one another. +/// +public sealed class JsonOrderRepository : IOrderRepository +{ + readonly List _documents = []; + + /// + public void Save(Order order) => _documents.Add(JsonSerializer.Serialize(order)); + + /// + public IReadOnlyList All() => + [.. _documents.Select(document => JsonSerializer.Deserialize(document)!)]; +} diff --git a/src/samples/ArchitectureSample/src/Persistence/Persistence.csproj b/src/samples/ArchitectureSample/src/Persistence.Json/Persistence.Json.csproj similarity index 73% rename from src/samples/ArchitectureSample/src/Persistence/Persistence.csproj rename to src/samples/ArchitectureSample/src/Persistence.Json/Persistence.Json.csproj index eeba661..67b9366 100644 --- a/src/samples/ArchitectureSample/src/Persistence/Persistence.csproj +++ b/src/samples/ArchitectureSample/src/Persistence.Json/Persistence.Json.csproj @@ -1,6 +1,7 @@ false + Purview.Architecture.Persistence.Json diff --git a/src/samples/ArchitectureSample/src/Persistence/OrderRepository.cs b/src/samples/ArchitectureSample/src/Persistence/OrderRepository.cs deleted file mode 100644 index 628a6bd..0000000 --- a/src/samples/ArchitectureSample/src/Persistence/OrderRepository.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Purview.Architecture.Domain; - -namespace Purview.Architecture.Persistence; - -/// Stores orders. Persistence may depend on the domain but not on the application. -public sealed class OrderRepository -{ - readonly List _orders = []; - - /// Saves an order. - /// The order to save. - public void Save(Order order) => _orders.Add(order); - - /// Returns every saved order. - /// The saved orders. - public IReadOnlyList All() => _orders; -} diff --git a/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj b/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj index 28e7c90..f5632ba 100644 --- a/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj +++ b/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj @@ -19,7 +19,9 @@ --> - + + + diff --git a/src/src/Architecture.CLI/APICaptureCommand.cs b/src/src/Architecture.CLI/APICaptureCommand.cs new file mode 100644 index 0000000..a78b04f --- /dev/null +++ b/src/src/Architecture.CLI/APICaptureCommand.cs @@ -0,0 +1,58 @@ +using Purview.Architecture.Testing; + +namespace Purview.Architecture.CLI; + +/// Writes the API baseline from the compiled assemblies. +static class APICaptureCommand +{ + /// The command's usage text. + public const string Usage = "Usage: architests api capture [--repository ] [--baseline ]"; + + /// Runs the command. + /// The arguments after api capture. + /// Where the result is written. + /// Where findings and usage errors are written. + /// The exit code. + public static int Run(IReadOnlyList args, TextWriter output, TextWriter error) + { + if (!CommandOptionsParser.TryParse(args, allowStale: false, Usage, error, out var options, out var exitCode)) + { + return exitCode; + } + + if (options.Help) + { + output.WriteLine(Usage); + return CommandLine.Success; + } + + var context = APIContext.TryLoad(options.Repository, error); + + if (context is null) + return CommandLine.Findings; + + var baseline = APIBaseline.Capture(context.Surfaces, context.Policy); + var path = context.BaselinePath(options.Baseline); + + try + { + baseline.Write(path); + } + catch (IOException exception) + { + error.WriteLine($"The baseline could not be written to '{path}': {exception.Message}"); + return CommandLine.Findings; + } + catch (UnauthorizedAccessException exception) + { + error.WriteLine($"The baseline could not be written to '{path}': {exception.Message}"); + return CommandLine.Findings; + } + + var types = baseline.Projects.Sum(project => project.Types.Count); + + output.WriteLine($"Captured {baseline.Projects.Count} project(s) and {types} public type(s) to {path}."); + + return CommandLine.Success; + } +} diff --git a/src/src/Architecture.CLI/APICheckCommand.cs b/src/src/Architecture.CLI/APICheckCommand.cs new file mode 100644 index 0000000..11cfbb9 --- /dev/null +++ b/src/src/Architecture.CLI/APICheckCommand.cs @@ -0,0 +1,158 @@ +using System.Text.Json; +using Purview.Architecture.Testing; + +namespace Purview.Architecture.CLI; + +/// Compares the compiled assemblies with the committed baseline. +static class APICheckCommand +{ + /// The command's usage text. + public const string Usage = + "Usage: architests api check [--repository ] [--baseline ] [--format text|json]"; + + static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// Runs the command. + /// The arguments after api check. + /// Where the result is written. + /// Where findings and usage errors are written. + /// The exit code. + public static int Run(IReadOnlyList args, TextWriter output, TextWriter error) + { + if (!CommandOptionsParser.TryParse(args, allowStale: false, Usage, error, out var options, out var exitCode)) + { + return exitCode; + } + + if (options.Help) + { + output.WriteLine(Usage); + return CommandLine.Success; + } + + if (options.Format is not ("text" or "json" or "sarif")) + { + error.WriteLine($"Unknown format '{options.Format}'. Use 'text', 'json' or 'sarif'."); + error.WriteLine(); + error.WriteLine(Usage); + return CommandLine.UsageError; + } + + var context = APIContext.TryLoad(options.Repository, error); + + if (context is null) + return CommandLine.Findings; + + var path = context.BaselinePath(options.Baseline); + + APIBaseline baseline; + + try + { + baseline = APIBaseline.Read(path); + } + catch (FileNotFoundException exception) + { + error.WriteLine(exception.Message); + return CommandLine.Findings; + } + catch (JsonException exception) + { + error.WriteLine($"The API baseline at '{path}' is not valid JSON: {exception.Message}"); + return CommandLine.Findings; + } + catch (InvalidOperationException exception) + { + error.WriteLine(exception.Message); + return CommandLine.Findings; + } + + var current = APIBaseline.Capture(context.Surfaces, context.Policy); + var changes = APIBaselineComparer.Compare(baseline, current); + + if (changes.Count == 0) + { + output.WriteLine("API matches the baseline."); + return CommandLine.Success; + } + + switch (options.Format) + { + case "json": + output.WriteLine(JsonSerializer.Serialize(changes, JsonOptions)); + break; + + case "sarif": + output.WriteLine(RenderSarif(changes, context.Root, path)); + break; + + default: + foreach (var change in changes) + output.WriteLine($"{change.Kind}: {change.Detail}"); + break; + } + + error.WriteLine( + $"{changes.Count} API difference(s) from '{path}'. Run 'architests api capture' after reviewing them." + ); + + return CommandLine.Findings; + } + + /// Renders the differences as a SARIF 2.1.0 document. + /// The differences. + /// The repository root, used for the artifact URI. + /// The baseline path. + /// The SARIF document. + static string RenderSarif(IReadOnlyList changes, string root, string baselinePath) + { + var uri = Path.GetRelativePath(root, baselinePath).Replace('\\', '/'); + + var results = changes + .Select(change => new Dictionary + { + ["ruleId"] = ArchitectureRules.APISurface, + ["level"] = change.Kind == APIBaselineChangeKind.Removed ? "error" : "warning", + ["message"] = new Dictionary { ["text"] = change.Detail }, + ["locations"] = new[] + { + new Dictionary + { + ["physicalLocation"] = new Dictionary + { + ["artifactLocation"] = new Dictionary { ["uri"] = uri }, + }, + }, + }, + }) + .ToArray(); + + Dictionary document = new() + { + ["$schema"] = "https://json.schemastore.org/sarif-2.1.0.json", + ["version"] = "2.1.0", + ["runs"] = new[] + { + new Dictionary + { + ["tool"] = new Dictionary + { + ["driver"] = new Dictionary + { + ["name"] = "architests", + ["version"] = CommandLine.Version, + ["informationUri"] = "https://github.com/purview-dev/architests", + }, + }, + ["results"] = results, + }, + }, + }; + + return JsonSerializer.Serialize(document, JsonOptions); + } +} diff --git a/src/src/Architecture.CLI/APICommand.cs b/src/src/Architecture.CLI/APICommand.cs new file mode 100644 index 0000000..16d50cc --- /dev/null +++ b/src/src/Architecture.CLI/APICommand.cs @@ -0,0 +1,46 @@ +namespace Purview.Architecture.CLI; + +/// Dispatches the API baseline commands. +static class APICommand +{ + /// The command's usage text. + public const string Usage = """ + Usage: architests api [options] + + capture Write the API baseline from the compiled assemblies. + check Compare the compiled assemblies with the baseline. + """; + + /// Runs the command. + /// The arguments after api. + /// Where ordinary output is written. + /// Where findings and usage errors are written. + /// The exit code. + public static int Run(IReadOnlyList args, TextWriter output, TextWriter error) + { + if (args.Count == 0) + { + error.WriteLine(Usage); + return CommandLine.UsageError; + } + + return args[0] switch + { + "capture" => APICaptureCommand.Run([.. args.Skip(1)], output, error), + "check" => APICheckCommand.Run([.. args.Skip(1)], output, error), + _ => Unknown(args[0], error), + }; + } + + /// Reports an unknown API verb. + /// The verb that was asked for. + /// Where the message is written. + /// The usage exit code. + static int Unknown(string verb, TextWriter error) + { + error.WriteLine($"Expected 'capture' or 'check', not '{verb}'."); + error.WriteLine(); + error.WriteLine(Usage); + return CommandLine.UsageError; + } +} diff --git a/src/src/Architecture.CLI/APIContext.cs b/src/src/Architecture.CLI/APIContext.cs new file mode 100644 index 0000000..9ebfc86 --- /dev/null +++ b/src/src/Architecture.CLI/APIContext.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using Purview.Architecture.Testing; + +namespace Purview.Architecture.CLI; + +/// The policy, project graph and public surfaces an API command works against. +/// The repository root. +/// The repository's policy. +/// The public surfaces, keyed by project name. +sealed record APIContext(string Root, ArchitecturePolicy Policy, IReadOnlyDictionary Surfaces) +{ + /// Resolves the baseline path for a command. + /// The --baseline override, or . + /// The absolute baseline path. + public string BaselinePath(string? baseline) + { + var relative = + baseline + ?? ( + string.IsNullOrWhiteSpace(Policy.APIRules.Baseline) + ? APIBaseline.RelativePath + : Policy.APIRules.Baseline + ); + + return Path.IsPathRooted(relative) ? relative : Path.Combine(Root, relative); + } + + /// Loads a repository, its policy and its compiled surfaces. + /// The repository root, or to probe upward. + /// Where a failure is written. + /// The context, or when it could not be loaded. + public static APIContext? TryLoad(string? repository, TextWriter error) + { + if (!RepositoryLocator.TryResolve(repository, error, out var root)) + return null; + + try + { + var policy = ArchitecturePolicy.Load(root); + var projects = ProjectGraph.Load(root, policy.SourceRoots); + + // An empty assemblyRoots means "the test output" to the evaluator, which is right inside a + // test run and wrong inside a tool. A command scans the repository's own build output. + var rules = + policy.AssemblyRules.AssemblyRoots.Count == 0 + ? new AssemblyRules { AssemblyRoots = policy.SourceRoots.Count == 0 ? ["."] : policy.SourceRoots } + : policy.AssemblyRules; + + var paths = AssemblyGraph.Discover(root, rules); + var surfaces = APISurface.Load(paths, projects); + + if (surfaces.Count == 0) + { + error.WriteLine( + "No project assemblies were found. Build the repository first, or check " + + "assemblyRules.assemblyRoots in the policy." + ); + return null; + } + + return new APIContext(root, policy, surfaces); + } + catch (JsonException exception) + { + error.WriteLine($"The architecture policy is not valid JSON: {exception.Message}"); + return null; + } + catch (InvalidOperationException exception) + { + error.WriteLine(exception.Message); + return null; + } + } +} diff --git a/src/src/Architecture.CLI/Architecture.CLI.csproj b/src/src/Architecture.CLI/Architecture.CLI.csproj new file mode 100644 index 0000000..9f72de4 --- /dev/null +++ b/src/src/Architecture.CLI/Architecture.CLI.csproj @@ -0,0 +1,57 @@ + + + Purview Architecture Testing CLI + Runs the Purview architecture policy outside TUnit: validates the policy document against its schema and integrity rules, lists the policy's waivers, and compares compiled assemblies with a committed API surface baseline. + architecture;architecture-tests;dependency-rules;dependency-matrix;policy;purview;dotnet;cli;tool + Purview.Architecture.Testing.Cli + true + + + architests + Purview.Architecture.CLI + net10.0 + + Major + Exe + true + architests + + true + + false + + + + + $(NoWarn);TUnit0034 + + false + false + + + + + + + diff --git a/src/src/Architecture.CLI/CommandLine.cs b/src/src/Architecture.CLI/CommandLine.cs new file mode 100644 index 0000000..c25faef --- /dev/null +++ b/src/src/Architecture.CLI/CommandLine.cs @@ -0,0 +1,118 @@ +using System.Reflection; + +namespace Purview.Architecture.CLI; + +/// Parses the command line and dispatches to one command. +/// +/// The command surface is small, so it is parsed directly rather than through a command-line +/// library: the tool keeps no dependency beyond the evaluator, and the parser is itself a unit +/// that can be tested. The exit codes are part of the contract, because a CI step reads them: +/// means the check passed, means the policy was +/// broken, and means the command line was wrong. +/// +public static class CommandLine +{ + /// The exit code for a clean run. + public const int Success = 0; + + /// The exit code for a run that found policy violations. + public const int Findings = 1; + + /// The exit code for a malformed command line. + public const int UsageError = 2; + + const string Usage = """ + architests - run the architecture policy outside TUnit + + Usage: + architests [options] + + Commands: + validate Validate the policy against its schema and its own integrity rules. + api capture Write the API baseline from the compiled assemblies. + api check Compare the compiled assemblies with the baseline. + waivers list List the policy's waivers, with their review dates. + graph Render the project graph. + + Options: + -h, --help Show this help. + --version Show the tool version. + """; + + /// The tool version, from the assembly's informational version. + public static string Version { get; } = ReadVersion(); + + /// Runs the tool. + /// The command-line arguments, without the executable name. + /// Where ordinary output is written. + /// Where usage errors are written. + /// The process exit code. + public static int Run(IReadOnlyList args, TextWriter output, TextWriter error) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (args.Count == 0) + { + output.WriteLine(Usage); + return Success; + } + + switch (args[0]) + { + case "-h": + case "--help": + case "help": + output.WriteLine(Usage); + return Success; + + case "--version": + case "version": + output.WriteLine(Version); + return Success; + + case "validate": + return ValidateCommand.Run([.. args.Skip(1)], output, error); + + case "api": + return APICommand.Run([.. args.Skip(1)], output, error); + + case "waivers": + return WaiversCommand.Run([.. args.Skip(1)], output, error); + + case "graph": + return GraphCommand.Run([.. args.Skip(1)], output, error); + + default: + return UnknownCommand(args[0], error); + } + } + + /// Reports a command the tool does not implement. + /// The command that was asked for. + /// Where the message is written. + /// . + static int UnknownCommand(string command, TextWriter error) + { + error.WriteLine($"Unknown command '{command}'."); + error.WriteLine(); + error.WriteLine(Usage); + return UsageError; + } + + /// Reads the informational version without its source-revision metadata. + /// The version, or unknown. + static string ReadVersion() + { + var assembly = typeof(CommandLine).Assembly; + var version = + assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + + // SourceLink appends "+", which is noise in a version banner. + var metadata = version.IndexOf('+', StringComparison.Ordinal); + return metadata < 0 ? version : version[..metadata]; + } +} diff --git a/src/src/Architecture.CLI/CommandOptions.cs b/src/src/Architecture.CLI/CommandOptions.cs new file mode 100644 index 0000000..761fb99 --- /dev/null +++ b/src/src/Architecture.CLI/CommandOptions.cs @@ -0,0 +1,149 @@ +namespace Purview.Architecture.CLI; + +/// The options a command accepts. +/// The repository root, or to probe upward. +/// Whether waivers list should show only stale waivers. +/// Whether the command was asked for its own help. +/// The API baseline path, or for the policy's path. +/// The output format: text or json. +sealed record CommandOptions(string? Repository, bool Stale, bool Help, string? Baseline, string Format); + +/// Parses the options shared by the tool's commands. +/// +/// The surface is deliberately tiny, so a hand-written parser keeps the tool dependency-free and +/// gives exact control of the exit code. Unknown input is a usage error rather than being ignored: +/// a typo in CI must not silently check the wrong thing. +/// +static class CommandOptionsParser +{ + /// Parses a command's arguments. + /// The arguments after the command name. + /// Whether the command accepts --stale. + /// The command's usage text, printed on a usage error. + /// Where a usage error is written. + /// The parsed options, when parsing succeeds. + /// The exit code to return when parsing fails. + /// when the arguments were understood. + public static bool TryParse( + IReadOnlyList args, + bool allowStale, + string usage, + TextWriter error, + out CommandOptions options, + out int exitCode + ) + { + string? repository = null; + string? baseline = null; + var format = "text"; + var stale = false; + var help = false; + + for (var index = 0; index < args.Count; index++) + { + var argument = args[index]; + + if (argument is "-h" or "--help") + { + help = true; + continue; + } + + if (argument == "--stale" && allowStale) + { + stale = true; + continue; + } + + if ( + TryRead(argument, args, ref index, "--repository", out var value) + || TryRead(argument, args, ref index, "-r", out value) + ) + { + if (string.IsNullOrWhiteSpace(value)) + { + exitCode = Fail("--repository needs a repository path.", usage, error); + options = new CommandOptions(null, false, false, null, "text"); + return false; + } + + repository = value; + continue; + } + + if (TryRead(argument, args, ref index, "--baseline", out value)) + { + if (string.IsNullOrWhiteSpace(value)) + { + exitCode = Fail("--baseline needs a path.", usage, error); + options = new CommandOptions(null, false, false, null, "text"); + return false; + } + + baseline = value; + continue; + } + + if (TryRead(argument, args, ref index, "--format", out value)) + { + if (string.IsNullOrWhiteSpace(value)) + { + exitCode = Fail("--format needs a value.", usage, error); + options = new CommandOptions(null, false, false, null, "text"); + return false; + } + + format = value; + continue; + } + + exitCode = Fail($"Unknown option '{argument}'.", usage, error); + options = new CommandOptions(null, false, false, null, "text"); + return false; + } + + options = new CommandOptions(repository, stale, help, baseline, format); + exitCode = CommandLine.Success; + return true; + } + + /// Reads a named option, in --name value or --name=value form. + /// The current argument. + /// All arguments, for the separate-value form. + /// The current index, advanced past a separate value. + /// The option name. + /// The value, or when it was omitted. + /// when the argument is the named option. + static bool TryRead(string argument, IReadOnlyList args, ref int index, string name, out string? value) + { + if (argument == name) + { + value = index + 1 < args.Count ? args[++index] : null; + return true; + } + + var prefix = name + "="; + + if (argument.StartsWith(prefix, StringComparison.Ordinal)) + { + value = argument[prefix.Length..]; + return true; + } + + value = null; + return false; + } + + /// Writes a usage error and its usage text. + /// The error. + /// The usage text. + /// Where to write. + /// The usage exit code. + static int Fail(string message, string usage, TextWriter error) + { + error.WriteLine(message); + error.WriteLine(); + error.WriteLine(usage); + return CommandLine.UsageError; + } +} diff --git a/src/src/Architecture.CLI/GraphCommand.cs b/src/src/Architecture.CLI/GraphCommand.cs new file mode 100644 index 0000000..aa7b2a5 --- /dev/null +++ b/src/src/Architecture.CLI/GraphCommand.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using System.Text; +using Purview.Architecture.Testing; + +namespace Purview.Architecture.CLI; + +/// Renders the project graph. +static class GraphCommand +{ + /// The command's usage text. + public const string Usage = "Usage: architests graph [--format text|mermaid] [--repository ]"; + + /// Runs the command. + /// The arguments after graph. + /// Where the graph is written. + /// Where findings and usage errors are written. + /// The exit code. + public static int Run(IReadOnlyList args, TextWriter output, TextWriter error) + { + if (!CommandOptionsParser.TryParse(args, allowStale: false, Usage, error, out var options, out var exitCode)) + { + return exitCode; + } + + if (options.Help) + { + output.WriteLine(Usage); + return CommandLine.Success; + } + + if (options.Format is not ("text" or "mermaid")) + { + error.WriteLine($"Unknown format '{options.Format}'. Use 'text' or 'mermaid'."); + error.WriteLine(); + error.WriteLine(Usage); + return CommandLine.UsageError; + } + + if (!RepositoryLocator.TryResolve(options.Repository, error, out var root)) + return CommandLine.Findings; + + try + { + var policy = ArchitecturePolicy.Load(root); + var projects = ProjectGraph.Load(root, policy.SourceRoots); + + if (projects.Count == 0) + { + error.WriteLine("No projects were found. Check sourceRoots in the policy, or build the repository."); + return CommandLine.Findings; + } + + output.Write(options.Format == "mermaid" ? RenderMermaid(projects, policy) : RenderText(projects, policy)); + + return CommandLine.Success; + } + catch (System.Text.Json.JsonException exception) + { + error.WriteLine($"The architecture policy is not valid JSON: {exception.Message}"); + return CommandLine.Findings; + } + catch (InvalidOperationException exception) + { + error.WriteLine(exception.Message); + return CommandLine.Findings; + } + } + + /// Renders the graph as an indented text tree. + /// The project graph. + /// The policy. + /// The rendering. + static string RenderText(IReadOnlyDictionary projects, ArchitecturePolicy policy) + { + StringBuilder builder = new(); + + foreach (var project in projects.Values.OrderBy(project => project.Name, StringComparer.Ordinal)) + { + var responsibility = policy.Classify(project.Name); + + var heading = responsibility is null + ? project.Name + : $"{project.Name} ({responsibility.Name}, {responsibility.Category})"; + + builder.AppendLine(heading); + + foreach (var reference in project.ProjectReferences.Order(StringComparer.Ordinal)) + builder.AppendLine(CultureInfo.InvariantCulture, $" -> {reference}"); + } + + return builder.ToString(); + } + + /// Renders the graph as a Mermaid flowchart. + /// The project graph. + /// The policy. + /// The rendering. + /// Mermaid node identifiers cannot contain dots, so they are replaced with underscores. + static string RenderMermaid(IReadOnlyDictionary projects, ArchitecturePolicy policy) + { + StringBuilder builder = new(); + builder.AppendLine("graph LR"); + + foreach (var project in projects.Values.OrderBy(project => project.Name, StringComparer.Ordinal)) + { + var responsibility = policy.Classify(project.Name); + var label = responsibility is null + ? project.Name + : $"{project.Name}
{responsibility.Name} · {responsibility.Category}"; + + builder.AppendLine(CultureInfo.InvariantCulture, $" {Id(project.Name)}[\"{label}\"]"); + } + + foreach (var project in projects.Values.OrderBy(project => project.Name, StringComparer.Ordinal)) + { + foreach (var reference in project.ProjectReferences.Order(StringComparer.Ordinal)) + { + if (projects.ContainsKey(reference)) + builder.AppendLine(CultureInfo.InvariantCulture, $" {Id(project.Name)} --> {Id(reference)}"); + } + } + + return builder.ToString(); + } + + /// Makes a Mermaid-safe node identifier. + /// The project name. + /// The identifier. + static string Id(string name) => + new([.. name.Select(character => char.IsLetterOrDigit(character) ? character : '_')]); +} diff --git a/src/src/Architecture.CLI/Program.cs b/src/src/Architecture.CLI/Program.cs new file mode 100644 index 0000000..b0e8412 --- /dev/null +++ b/src/src/Architecture.CLI/Program.cs @@ -0,0 +1,10 @@ +namespace Purview.Architecture.CLI; + +/// The process entry point. +static class Program +{ + /// Runs the command line. + /// The arguments, without the executable name. + /// The process exit code. + static int Main(string[] args) => CommandLine.Run(args, Console.Out, Console.Error); +} diff --git a/src/src/Architecture.CLI/README.md b/src/src/Architecture.CLI/README.md new file mode 100644 index 0000000..ce4a691 --- /dev/null +++ b/src/src/Architecture.CLI/README.md @@ -0,0 +1,67 @@ +# Purview.Architecture.Testing.Cli + +The `architests` command-line tool: run the parts of a +[Purview.Architecture.Testing](https://github.com/purview-dev/architests) policy that need no test +run or build. + +## Install + +```bash +dotnet tool install --global Purview.Architecture.Testing.Cli +``` + +## Use + +```text +architests validate # schema + policyIntegrity +architests api capture # write .config/api-surface.json +architests api check [--format text|json|sarif] +architests waivers list [--stale] # the policy's waivers and their review state +architests graph [--format text|mermaid] +architests --help +architests --version +``` + +Both commands accept `--repository ` (or `-r`). Without it, the tool walks up from the +current directory to the folder holding `.config/architecture-policy.json`, and stops at the +repository boundary rather than reading a parent folder's policy. + +### Exit codes + +| Code | Meaning | +| -- | -- | +| `0` | The check passed. | +| `1` | Findings: the policy is invalid, inconsistent, or a stale waiver was requested. | +| `2` | A malformed command line: unknown command, unknown option, or a missing option value. | + +### `validate` + +Validates the policy against the schema embedded in the package and runs `policyIntegrity`, which +reports unknown responsibility names, a dependency both permitted and forbidden, duplicate names or +aliases, and — when `checkReachability` is on — a prohibition the permitted dependencies can bypass. +It needs no build, so it suits a pre-commit hook or a fast CI job. + +### `api capture` and `api check` + +`capture` writes the public API baseline from the compiled assemblies; `check` compares the build +with it and exits `1` on any difference. The document is canonical, so an unchanged API regenerates +byte for byte. Both scan the repository's build output, so build first. + +### `graph` + +`graph` renders the project graph from the policy's `sourceRoots`; `--format mermaid` emits a +flowchart, and the default text output lists each project's responsibility and references. + +### `waivers list` + +Lists each waiver with its rules and review state. `--stale` shows only waivers due under +`waiverPolicy.maxAgeDays`, and exits `1` when any is. + +## Documentation + +- [Command line](https://github.com/purview-dev/architests/blob/main/docs/Cli.md) +- [Policy reference](https://github.com/purview-dev/architests/blob/main/docs/Policy-Reference.md) + +--- + +Part of [Purview.BuildSdk](https://github.com/purview-dev/build-sdk) · [purview.dev](https://purview.dev/projects/architests/) diff --git a/src/src/Architecture.CLI/RepositoryLocator.cs b/src/src/Architecture.CLI/RepositoryLocator.cs new file mode 100644 index 0000000..b15f43c --- /dev/null +++ b/src/src/Architecture.CLI/RepositoryLocator.cs @@ -0,0 +1,52 @@ +using Purview.Architecture.Testing; + +namespace Purview.Architecture.CLI; + +/// Resolves the repository root a command runs against. +static class RepositoryLocator +{ + /// Resolves the root and confirms a policy exists there. + /// + /// The repository root, or to probe upward from the working directory. + /// + /// Where a resolution failure is written. + /// The resolved repository root. + /// when a root and its policy were found. + public static bool TryResolve(string? repository, TextWriter error, out string root) + { + if (repository is not null) + { + root = Path.GetFullPath(repository); + + if (!Directory.Exists(root)) + { + error.WriteLine($"The repository folder '{root}' does not exist."); + return false; + } + } + else + { + try + { + // A tool runs from the repository being checked, not from its own install folder. + root = ArchitecturePolicy.RepositoryRoot(Directory.GetCurrentDirectory()); + } + catch (InvalidOperationException exception) + { + root = string.Empty; + error.WriteLine(exception.Message); + return false; + } + } + + var policyPath = Path.Combine(root, ArchitecturePolicy.RelativePath); + + if (!File.Exists(policyPath)) + { + error.WriteLine($"No architecture policy was found at '{policyPath}'. Add one, or pass --repository."); + return false; + } + + return true; + } +} diff --git a/src/src/Architecture.CLI/ValidateCommand.cs b/src/src/Architecture.CLI/ValidateCommand.cs new file mode 100644 index 0000000..772bcd4 --- /dev/null +++ b/src/src/Architecture.CLI/ValidateCommand.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Purview.Architecture.Testing; + +namespace Purview.Architecture.CLI; + +/// Validates a policy without needing a build. +static class ValidateCommand +{ + /// The command's usage text. + public const string Usage = "Usage: architests validate [--repository ]"; + + /// Runs the command. + /// The arguments after the command name. + /// Where ordinary output is written. + /// Where findings and usage errors are written. + /// The exit code. + public static int Run(IReadOnlyList args, TextWriter output, TextWriter error) + { + if (!CommandOptionsParser.TryParse(args, allowStale: false, Usage, error, out var options, out var exitCode)) + return exitCode; + + if (options.Help) + { + output.WriteLine(Usage); + return CommandLine.Success; + } + + if (!RepositoryLocator.TryResolve(options.Repository, error, out var root)) + return CommandLine.Findings; + + var policyPath = Path.Combine(root, ArchitecturePolicy.RelativePath); + + try + { + var schemaFailures = PolicySchemaValidator.Validate(policyPath); + + if (schemaFailures.Count > 0) + { + error.WriteLine($"The policy at '{policyPath}' does not match its schema:"); + foreach (var failure in schemaFailures) + error.WriteLine($" {failure}"); + + return CommandLine.Findings; + } + + var policy = ArchitecturePolicy.Load(root); + ArchitecturePolicyEvaluator evaluator = new(policy, new Dictionary()); + var violations = evaluator.PolicyIntegrity(); + + if (violations.Count > 0) + { + error.WriteLine($"The policy at '{policyPath}' is not internally consistent:"); + error.WriteLine(ArchitecturePolicyEvaluator.Report(violations)); + return CommandLine.Findings; + } + } + catch (JsonException exception) + { + error.WriteLine($"The policy at '{policyPath}' is not valid JSON: {exception.Message}"); + return CommandLine.Findings; + } + catch (InvalidOperationException exception) + { + error.WriteLine(exception.Message); + return CommandLine.Findings; + } + + output.WriteLine($"Policy is valid: {policyPath}"); + return CommandLine.Success; + } +} diff --git a/src/src/Architecture.CLI/WaiversCommand.cs b/src/src/Architecture.CLI/WaiversCommand.cs new file mode 100644 index 0000000..50a7c32 --- /dev/null +++ b/src/src/Architecture.CLI/WaiversCommand.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using Purview.Architecture.Testing; + +namespace Purview.Architecture.CLI; + +/// Lists a policy's waivers and their review state. +static class WaiversCommand +{ + /// The command's usage text. + public const string Usage = "Usage: architests waivers list [--stale] [--repository ]"; + + /// Runs the command. + /// The arguments after waivers. + /// Where the listing is written. + /// Where findings and usage errors are written. + /// The exit code. + public static int Run(IReadOnlyList args, TextWriter output, TextWriter error) + { + if (args.Count == 0 || args[0] != "list") + { + error.WriteLine("Expected 'list'."); + error.WriteLine(); + error.WriteLine(Usage); + return CommandLine.UsageError; + } + + if ( + !CommandOptionsParser.TryParse( + [.. args.Skip(1)], + allowStale: true, + Usage, + error, + out var options, + out var exitCode + ) + ) + { + return exitCode; + } + + if (options.Help) + { + output.WriteLine(Usage); + return CommandLine.Success; + } + + if (!RepositoryLocator.TryResolve(options.Repository, error, out var root)) + return CommandLine.Findings; + + var policyPath = Path.Combine(root, ArchitecturePolicy.RelativePath); + + ArchitecturePolicy policy; + + try + { + var schemaFailures = PolicySchemaValidator.Validate(policyPath); + + if (schemaFailures.Count > 0) + { + error.WriteLine($"The policy at '{policyPath}' does not match its schema:"); + foreach (var failure in schemaFailures) + error.WriteLine($" {failure}"); + + return CommandLine.Findings; + } + + policy = ArchitecturePolicy.Load(root); + } + catch (JsonException exception) + { + error.WriteLine($"The policy at '{policyPath}' is not valid JSON: {exception.Message}"); + return CommandLine.Findings; + } + catch (InvalidOperationException exception) + { + error.WriteLine(exception.Message); + return CommandLine.Findings; + } + + var reviews = WaiverReviewer.Review(policy, DateOnly.FromDateTime(DateTime.UtcNow)); + + if (reviews.Count == 0) + { + output.WriteLine("No waivers."); + return CommandLine.Success; + } + + var selected = options.Stale ? [.. reviews.Where(review => review.IsStale)] : reviews; + + if (selected.Count == 0) + { + output.WriteLine("No stale waivers."); + return CommandLine.Success; + } + + foreach (var review in selected) + { + output.WriteLine($"{review.Waiver.Project} [{string.Join(", ", review.Waiver.Rules)}]: {review.Reason}"); + } + + return options.Stale ? CommandLine.Findings : CommandLine.Success; + } +} diff --git a/src/src/ArchitectureTesting/APIBaseline.cs b/src/src/ArchitectureTesting/APIBaseline.cs new file mode 100644 index 0000000..b5e47dc --- /dev/null +++ b/src/src/ArchitectureTesting/APIBaseline.cs @@ -0,0 +1,167 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Purview.Architecture.Testing; + +/// A committed snapshot of the public API, grouped by project and responsibility. +/// The baseline document version. +/// The projects and their public declarations. +/// +/// The baseline is the reviewable record of the API a consumer receives. It is written by +/// architests api capture, compared by architests api check and by the +/// apiSurface rule, and it is canonical: sorted, stable and free of timestamps, so an +/// unchanged API regenerates byte for byte. +/// +public sealed record APIBaseline(int Version, IReadOnlyList Projects) +{ + /// The baseline document version this package writes. + public const int CurrentVersion = 1; + + static readonly JsonSerializerOptions ReaderOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + static readonly JsonSerializerOptions WriterOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// The baseline file's location relative to a repository root. + public static string RelativePath { get; } = Path.Combine(".config", "api-surface.json"); + + /// Captures the public API of every project assembly. + /// The API surfaces, keyed by project name. + /// The policy, used to record each project's responsibility. + /// The baseline. + public static APIBaseline Capture(IReadOnlyDictionary assemblies, ArchitecturePolicy policy) + { + ArgumentNullException.ThrowIfNull(assemblies); + ArgumentNullException.ThrowIfNull(policy); + + return new APIBaseline( + CurrentVersion, + [ + .. assemblies + .Values.Where(assembly => assembly.Types.Any(type => type.IsPublic)) + .Select(assembly => new APIBaselineProject( + assembly.ProjectName, + policy.Classify(assembly.ProjectName)?.Name ?? string.Empty, + [.. assembly.Types.Where(type => type.IsPublic).Select(TypeEntry)] + )), + ] + ).Sorted(); + } + + /// Reads a baseline file. + /// The baseline path. + /// The baseline. + /// The file does not exist. + public static APIBaseline Read(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (!File.Exists(path)) + { + throw new FileNotFoundException( + $"The API baseline was not found at '{path}'. Run 'architests api capture' to write it.", + path + ); + } + + // The baseline is canonical JSON, so we can use the default serializer to read it. + return JsonSerializer.Deserialize(File.ReadAllText(path), ReaderOptions) + ?? throw new InvalidOperationException($"The API baseline at '{path}' deserialised to null."); + } + + /// Writes the baseline as canonical JSON. + /// The path to write. + public void Write(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(path, ToCanonicalJson()); + } + + /// Renders the baseline as canonical JSON. + /// + /// Sorted, indented JSON with a trailing newline, using forward line endings so the file is + /// identical on Windows, Linux and macOS. + /// + public string ToCanonicalJson() => + JsonSerializer.Serialize(Sorted(), WriterOptions).ReplaceLineEndings("\n") + "\n"; + + /// Sorts every list so an unchanged API produces an identical document. + /// The sorted baseline. + public APIBaseline Sorted() => + new( + Version, + [ + .. Projects + .OrderBy(project => project.Project, StringComparer.Ordinal) + .Select(project => + project with + { + Types = + [ + .. project + .Types.OrderBy(type => type.Name, StringComparer.Ordinal) + .Select(type => + type with + { + Interfaces = + [ + .. type.Interfaces.OrderBy(value => value, StringComparer.Ordinal), + ], + Members = + [ + .. type + .Members.OrderBy(member => member.Kind, StringComparer.Ordinal) + .ThenBy(member => member.Name, StringComparer.Ordinal) + .ThenBy( + member => string.Join(",", member.Parameters), + StringComparer.Ordinal + ), + ], + } + ), + ], + } + ), + ] + ); + + /// Builds the baseline entry for a type. + /// The type. + /// The entry. + static APIBaselineType TypeEntry(APIType type) => + new( + type.FullName, + type.Kind.ToString(), + type.IsAbstract, + type.IsSealed, + type.BaseType, + [.. type.Interfaces], + [ + .. type.Members.Select(member => new APIBaselineMember( + member.Kind.ToString(), + member.Name, + member.TypeName, + member.IsStatic, + [.. member.Parameters] + )), + ] + ); +} diff --git a/src/src/ArchitectureTesting/APIBaselineComparer.Types.cs b/src/src/ArchitectureTesting/APIBaselineComparer.Types.cs new file mode 100644 index 0000000..51194a3 --- /dev/null +++ b/src/src/ArchitectureTesting/APIBaselineComparer.Types.cs @@ -0,0 +1,149 @@ +namespace Purview.Architecture.Testing; + +/// Compares the shape and members of one type. +public static partial class APIBaselineComparer +{ + /// Compares one type's shape and members. + /// The project name. + /// The baseline type. + /// The captured type. + /// The accumulating differences. + static void CompareType( + string project, + APIBaselineType expected, + APIBaselineType current, + List changes + ) + { + List details = []; + + if (!string.Equals(expected.Kind, current.Kind, StringComparison.Ordinal)) + details.Add($"kind changed from {expected.Kind} to {current.Kind}"); + + if (expected.IsAbstract != current.IsAbstract) + details.Add($"abstract changed from {expected.IsAbstract} to {current.IsAbstract}"); + + if (expected.IsSealed != current.IsSealed) + details.Add($"sealed changed from {expected.IsSealed} to {current.IsSealed}"); + + if (!string.Equals(expected.BaseType, current.BaseType, StringComparison.Ordinal)) + { + details.Add( + $"base type changed from '{expected.BaseType ?? "(none)"}' to '{current.BaseType ?? "(none)"}'" + ); + } + + var removedInterfaces = expected.Interfaces.Except(current.Interfaces, StringComparer.Ordinal).ToArray(); + var addedInterfaces = current.Interfaces.Except(expected.Interfaces, StringComparer.Ordinal).ToArray(); + + if (removedInterfaces.Length > 0) + details.Add($"interfaces removed: {string.Join(", ", removedInterfaces)}"); + + if (addedInterfaces.Length > 0) + details.Add($"interfaces added: {string.Join(", ", addedInterfaces)}"); + + if (details.Count > 0) + { + changes.Add( + new APIBaselineChange( + APIBaselineChangeKind.Changed, + project, + expected.Name, + $"'{expected.Name}' changed: {string.Join("; ", details)}. " + + "Run 'architests api capture' after reviewing the change." + ) + ); + } + + CompareMembers(project, expected, current, changes); + } + + /// Compares one type's members, matching overloads by kind, name and parameters. + /// The project name. + /// The baseline type. + /// The captured type. + /// The accumulating differences. + static void CompareMembers( + string project, + APIBaselineType expected, + APIBaselineType current, + List changes + ) + { + var expectedMembers = expected.Members.ToDictionary(MemberKey, StringComparer.Ordinal); + var actualMembers = current.Members.ToDictionary(MemberKey, StringComparer.Ordinal); + + foreach (var key in expectedMembers.Keys.Except(actualMembers.Keys, StringComparer.Ordinal)) + { + var subject = MemberSubject(expected.Name, expectedMembers[key]); + + changes.Add( + new APIBaselineChange( + APIBaselineChangeKind.Removed, + project, + subject, + $"'{subject}' is gone. This is a breaking change: restore it, or capture the baseline as a " + + "deliberate breaking release." + ) + ); + } + + foreach (var key in actualMembers.Keys.Except(expectedMembers.Keys, StringComparer.Ordinal)) + { + var subject = MemberSubject(expected.Name, actualMembers[key]); + + changes.Add( + new APIBaselineChange( + APIBaselineChangeKind.Added, + project, + subject, + $"'{subject}' is new public API. Run 'architests api capture' and review it before committing." + ) + ); + } + + foreach (var key in expectedMembers.Keys.Intersect(actualMembers.Keys, StringComparer.Ordinal)) + { + var before = expectedMembers[key]; + var after = actualMembers[key]; + + if ( + string.Equals(before.TypeName, after.TypeName, StringComparison.Ordinal) + && before.IsStatic == after.IsStatic + ) + { + continue; + } + + var detail = !string.Equals(before.TypeName, after.TypeName, StringComparison.Ordinal) + ? $"type changed from '{before.TypeName}' to '{after.TypeName}'" + : $"static changed from {before.IsStatic} to {after.IsStatic}"; + + var subject = MemberSubject(expected.Name, before); + + changes.Add( + new APIBaselineChange( + APIBaselineChangeKind.Changed, + project, + subject, + $"'{subject}' changed: {detail}. Run 'architests api capture' after reviewing the change." + ) + ); + } + } + + /// Builds the identity of a member, so an overload is not confused with its sibling. + /// The member. + /// The identity. + static string MemberKey(APIBaselineMember member) => + $"{member.Kind}|{member.Name}|{string.Join(",", member.Parameters)}"; + + /// Renders a member for a failure message, including its parameters. + /// The declaring type. + /// The member. + /// The subject. + static string MemberSubject(string typeName, APIBaselineMember member) => + member.Parameters.Count == 0 + ? $"{typeName}.{member.Name}" + : $"{typeName}.{member.Name}({string.Join(", ", member.Parameters)})"; +} diff --git a/src/src/ArchitectureTesting/APIBaselineComparer.cs b/src/src/ArchitectureTesting/APIBaselineComparer.cs new file mode 100644 index 0000000..673d196 --- /dev/null +++ b/src/src/ArchitectureTesting/APIBaselineComparer.cs @@ -0,0 +1,117 @@ +namespace Purview.Architecture.Testing; + +/// How the current public API differs from its baseline. +public enum APIBaselineChangeKind +{ + /// API exists in the build but not in the baseline. + Added, + + /// API exists in the baseline but not in the build. + Removed, + + /// API exists in both, but its shape changed. + Changed, +} + +/// One difference between a baseline and the current public API. +/// Whether the API was added, removed or changed. +/// The project whose API differs. +/// The type or member that differs. +/// What changed and what to do about it. +public sealed record APIBaselineChange(APIBaselineChangeKind Kind, string Project, string Subject, string Detail) +{ + /// + public override string ToString() => Detail; +} + +/// Compares a committed baseline with the API a build produced. +public static partial class APIBaselineComparer +{ + /// Compares two baselines. + /// The committed baseline. + /// The API captured from the current build. + /// The differences, with removals first. + public static IReadOnlyList Compare(APIBaseline baseline, APIBaseline current) + { + ArgumentNullException.ThrowIfNull(baseline); + ArgumentNullException.ThrowIfNull(current); + + List changes = []; + + var expected = baseline.Projects.ToDictionary(project => project.Project, StringComparer.OrdinalIgnoreCase); + var actual = current.Projects.ToDictionary(project => project.Project, StringComparer.OrdinalIgnoreCase); + + foreach (var project in expected.Keys.Except(actual.Keys, StringComparer.OrdinalIgnoreCase)) + { + changes.Add( + new APIBaselineChange( + APIBaselineChangeKind.Removed, + project, + project, + $"'{project}' contributes no public API to the build, but the baseline records it. " + + "Restore the assembly, or run 'architests api capture' if the removal is intended." + ) + ); + } + + foreach (var project in actual.Keys.Except(expected.Keys, StringComparer.OrdinalIgnoreCase)) + { + changes.Add( + new APIBaselineChange( + APIBaselineChangeKind.Added, + project, + project, + $"'{project}' contributes public API the baseline does not record. " + + "Run 'architests api capture' and review the added surface before committing it." + ) + ); + } + + foreach (var project in expected.Keys.Intersect(actual.Keys, StringComparer.OrdinalIgnoreCase)) + { + CompareProject(expected[project], actual[project], changes); + } + + return changes; + } + + /// Compares one project's types. + /// The baseline project. + /// The captured project. + /// The accumulating differences. + static void CompareProject(APIBaselineProject expected, APIBaselineProject current, List changes) + { + var expectedTypes = expected.Types.ToDictionary(type => type.Name, StringComparer.Ordinal); + var actualTypes = current.Types.ToDictionary(type => type.Name, StringComparer.Ordinal); + + foreach (var name in expectedTypes.Keys.Except(actualTypes.Keys, StringComparer.Ordinal)) + { + changes.Add( + new APIBaselineChange( + APIBaselineChangeKind.Removed, + expected.Project, + name, + $"'{name}' was public and is gone. This is a breaking change: restore it, or capture the " + + "baseline as a deliberate breaking release." + ) + ); + } + + foreach (var name in actualTypes.Keys.Except(expectedTypes.Keys, StringComparer.Ordinal)) + { + changes.Add( + new APIBaselineChange( + APIBaselineChangeKind.Added, + expected.Project, + name, + $"'{name}' is new public API. Run 'architests api capture' and review it before committing." + ) + ); + } + + foreach (var name in expectedTypes.Keys.Intersect(actualTypes.Keys, StringComparer.Ordinal)) + { + CompareType(expected.Project, expectedTypes[name], actualTypes[name], changes); + } + } +} diff --git a/src/src/ArchitectureTesting/APIBaselineModel.cs b/src/src/ArchitectureTesting/APIBaselineModel.cs new file mode 100644 index 0000000..17c296a --- /dev/null +++ b/src/src/ArchitectureTesting/APIBaselineModel.cs @@ -0,0 +1,39 @@ +namespace Purview.Architecture.Testing; + +/// A project's public API in a baseline. +/// The project name. +/// The responsibility the project maps to, for review context. +/// The public types the project declares. +public sealed record APIBaselineProject(string Project, string Responsibility, IReadOnlyList Types); + +/// A public type in a baseline. +/// The namespace-qualified type name. +/// The type kind, as its enum name. +/// Whether the type is abstract. +/// Whether the type is sealed. +/// The base type, when there is one. +/// The implemented interfaces. +/// The public members. +public sealed record APIBaselineType( + string Name, + string Kind, + bool IsAbstract, + bool IsSealed, + string? BaseType, + IReadOnlyList Interfaces, + IReadOnlyList Members +); + +/// A public member in a baseline. +/// The member kind, as its enum name. +/// The member name. +/// The declared type. +/// Whether the member is static. +/// The parameter types, in order. +public sealed record APIBaselineMember( + string Kind, + string Name, + string TypeName, + bool IsStatic, + IReadOnlyList Parameters +); diff --git a/src/src/ArchitectureTesting/APIModel.cs b/src/src/ArchitectureTesting/APIModel.cs new file mode 100644 index 0000000..16e48b4 --- /dev/null +++ b/src/src/ArchitectureTesting/APIModel.cs @@ -0,0 +1,145 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Purview.Architecture.Testing; + +/// The kind of a type declared in an assembly. +public enum APITypeKind +{ + /// A class, including a record class. + Class, + + /// A value type, including a record struct. + Struct, + + /// An interface. + Interface, + + /// An enumeration. + Enum, + + /// A delegate. + Delegate, +} + +/// The kind of a member declared on a type. +public enum APIMemberKind +{ + /// A method that is not a property, event or operator accessor. + Method, + + /// A property, including an indexer. + Property, + + /// A field, including a constant. + Field, + + /// An event. + Event, + + /// An instance or static constructor. + Constructor, +} + +/// An attribute applied to a type, member, or assembly. +/// The attribute's namespace-qualified type name. +/// +/// The attribute's first string argument, when it has one. Only the shape rules need it, for +/// [Obsolete("...")] and [InternalsVisibleTo("...")]. +/// +[SuppressMessage( + "Naming", + "CA1711:Identifiers should not have incorrect suffix", + Justification = "This record models an attribute instance; it is not itself an attribute class." +)] +public sealed record APIAttribute(string TypeName, string? StringArgument); + +/// A public member declared on a type. +/// The member's metadata name. +/// Whether it is a method, property, field, event or constructor. +/// +/// The declared type: the return type, property type, field type or event delegate type. A +/// constructor uses System.Void. +/// +/// Whether the member is static. +/// The attributes applied to the member. +public sealed record APIMember( + string Name, + APIMemberKind Kind, + string TypeName, + bool IsStatic, + IReadOnlyList Attributes +) +{ + /// The parameter types, in order. Empty for a field, property or event. + public IReadOnlyList Parameters { get; init; } = []; +} + +/// A compiled assembly's declaration surface and assembly-level attributes. +/// The project the assembly belongs to. +/// The assembly's simple name. +/// The assembly-level attributes. +/// The types the assembly declares, including internal and nested types. +public sealed record APIAssembly( + string ProjectName, + string AssemblyName, + IReadOnlyList Attributes, + IReadOnlyList Types +) +{ + /// The assembly file path, used to find its XML documentation beside it. + public string Path { get; init; } = string.Empty; +} + +/// A type declared in a compiled assembly. +/// The project the assembly belongs to, or empty when it is not a project. +/// The type's namespace, or empty for a global type. +/// The simple metadata name, without the generic arity suffix. +/// The namespace-qualified name, including any declaring type. +/// Whether it is a class, struct, interface, enum or delegate. +/// Whether the type is public, including a public nested type. +/// Whether the type is abstract. A static class is also sealed. +/// Whether the type is sealed. +/// Whether the type is declared inside another type. +/// The namespace-qualified base type, when there is one. +/// The interfaces the type implements. +/// The public members the type declares. +/// The attributes applied to the type. +public sealed record APIType( + string ProjectName, + string Namespace, + string Name, + string FullName, + APITypeKind Kind, + bool IsPublic, + bool IsAbstract, + bool IsSealed, + bool IsNested, + string? BaseType, + IReadOnlyList Interfaces, + IReadOnlyList Members, + IReadOnlyList Attributes +) +{ + /// When true the type is a static class: abstract and sealed in metadata. + public bool IsStatic => Kind == APITypeKind.Class && IsAbstract && IsSealed; + + /// When true the compiler or a tool marked the type as generated. + public bool IsGeneratedCode => + Attributes.Any(attribute => + attribute.TypeName + is "System.CodeDom.Compiler.GeneratedCodeAttribute" + or "System.Runtime.CompilerServices.CompilerGeneratedAttribute" + ); + + /// When true the type is an attribute class. + public bool IsAttribute => string.Equals(BaseType, "System.Attribute", StringComparison.Ordinal); + + /// When true the type is an exception class, directly or through a known exception base. + public bool IsException => + string.Equals(BaseType, "System.Exception", StringComparison.Ordinal) + || ( + BaseType is not null + && BaseType.EndsWith("Exception", StringComparison.Ordinal) + && !BaseType.Contains('<', StringComparison.Ordinal) + ); +} diff --git a/src/src/ArchitectureTesting/APISignatureProvider.cs b/src/src/ArchitectureTesting/APISignatureProvider.cs new file mode 100644 index 0000000..b65046a --- /dev/null +++ b/src/src/ArchitectureTesting/APISignatureProvider.cs @@ -0,0 +1,115 @@ +using System.Collections.Immutable; +using System.Reflection.Metadata; + +namespace Purview.Architecture.Testing; + +/// Renders metadata signatures as namespace-qualified type names. +/// +/// The reader needs names, not objects: the whole point is that nothing is loaded +/// or executed. Generic arguments are rendered inline, as in IReadOnlyList<System.String>, +/// so a rule can see the shape of an exposed collection without resolving it. +/// +sealed class APISignatureProvider : ISignatureTypeProvider, ICustomAttributeTypeProvider +{ + /// The shared instance: the provider carries no state. + public static readonly APISignatureProvider Instance = new(); + + /// + public string GetArrayType(string elementType, ArrayShape shape) => + $"{elementType}[{new string(',', Math.Max(0, shape.Rank - 1))}]"; + + /// + public string GetByReferenceType(string elementType) => $"{elementType}&"; + + /// + public string GetFunctionPointerType(MethodSignature signature) => "method*"; + + /// + public string GetGenericInstantiation(string genericType, ImmutableArray typeArguments) => + $"{TrimArity(genericType)}<{string.Join(", ", typeArguments)}>"; + + /// + public string GetGenericMethodParameter(object? genericContext, int index) => $"!!{index}"; + + /// + public string GetGenericTypeParameter(object? genericContext, int index) => $"!{index}"; + + /// + public string GetModifiedType(string modifier, string unmodifiedType, bool isRequired) => unmodifiedType; + + /// + public string GetPinnedType(string elementType) => elementType; + + /// + public string GetPointerType(string elementType) => $"{elementType}*"; + + /// + public string GetPrimitiveType(PrimitiveTypeCode typeCode) => + typeCode switch + { + PrimitiveTypeCode.Boolean => "System.Boolean", + PrimitiveTypeCode.Byte => "System.Byte", + PrimitiveTypeCode.Char => "System.Char", + PrimitiveTypeCode.Double => "System.Double", + PrimitiveTypeCode.Int16 => "System.Int16", + PrimitiveTypeCode.Int32 => "System.Int32", + PrimitiveTypeCode.Int64 => "System.Int64", + PrimitiveTypeCode.IntPtr => "System.IntPtr", + PrimitiveTypeCode.Object => "System.Object", + PrimitiveTypeCode.SByte => "System.SByte", + PrimitiveTypeCode.Single => "System.Single", + PrimitiveTypeCode.String => "System.String", + PrimitiveTypeCode.TypedReference => "System.TypedReference", + PrimitiveTypeCode.UInt16 => "System.UInt16", + PrimitiveTypeCode.UInt32 => "System.UInt32", + PrimitiveTypeCode.UInt64 => "System.UInt64", + PrimitiveTypeCode.UIntPtr => "System.UIntPtr", + PrimitiveTypeCode.Void => "System.Void", + _ => typeCode.ToString(), + }; + + /// + public string GetSZArrayType(string elementType) => $"{elementType}[]"; + + /// + public string GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => + MetadataNames.Definition(reader, handle); + + /// + public string GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) => + MetadataNames.Reference(reader, handle); + + /// + public string GetTypeFromSpecification( + MetadataReader reader, + object? genericContext, + TypeSpecificationHandle handle, + byte rawTypeKind + ) => reader.GetTypeSpecification(handle).DecodeSignature(this, genericContext); + + /// + public string GetSystemType() => "System.Type"; + + /// + public string GetTypeFromSerializedName(string name) + { + // A serialized attribute argument is assembly-qualified, e.g. "Ns.Type, Assembly". + var comma = name.IndexOf(',', StringComparison.Ordinal); + return (comma < 0 ? name : name[..comma]).Replace('+', '.'); + } + + /// + public PrimitiveTypeCode GetUnderlyingEnumType(string type) => PrimitiveTypeCode.Int32; + + /// + public bool IsSystemType(string type) => string.Equals(type, "System.Type", StringComparison.Ordinal); + + /// Removes the generic arity suffix the compiler puts in a metadata name. + /// The metadata name. + /// The name without `n. + public static string TrimArity(string name) + { + var arity = name.IndexOf('`', StringComparison.Ordinal); + return arity < 0 ? name : name[..arity]; + } +} diff --git a/src/src/ArchitectureTesting/APISurface.Attributes.cs b/src/src/ArchitectureTesting/APISurface.Attributes.cs new file mode 100644 index 0000000..1f31c92 --- /dev/null +++ b/src/src/ArchitectureTesting/APISurface.Attributes.cs @@ -0,0 +1,62 @@ +using System.Reflection.Metadata; + +namespace Purview.Architecture.Testing; + +/// Reads custom attributes from metadata. +public static partial class APISurface +{ + /// Reads a collection of attributes. + /// The assembly's metadata. + /// The attribute handles. + /// The attributes, in metadata order. + static List ReadAttributes(MetadataReader reader, CustomAttributeHandleCollection handles) + { + List attributes = []; + + foreach (var handle in handles) + { + var attribute = reader.GetCustomAttribute(handle); + string? argument = null; + + try + { + argument = attribute + .DecodeValue(APISignatureProvider.Instance) + .FixedArguments.Select(fixedArgument => fixedArgument.Value) + .OfType() + .FirstOrDefault(); + } + catch (BadImageFormatException) + { + // An attribute whose blob this reader cannot decode still has a name, and the name is + // what the policy governs; the argument is a bonus. + } + + attributes.Add(new APIAttribute(AttributeTypeName(reader, attribute) ?? string.Empty, argument)); + } + + return attributes; + } + + /// Resolves the type of an attribute from its constructor. + /// The assembly's metadata. + /// The attribute. + /// The attribute type's name, or . + static string? AttributeTypeName(MetadataReader reader, CustomAttribute attribute) + { +#pragma warning disable IDE0072 // Add missing cases + var declaring = attribute.Constructor.Kind switch + { + HandleKind.MethodDefinition => reader + .GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor) + .GetDeclaringType(), + HandleKind.MemberReference => reader + .GetMemberReference((MemberReferenceHandle)attribute.Constructor) + .Parent, + _ => default, + }; +#pragma warning restore IDE0072 // Add missing cases + + return declaring.IsNil ? null : MetadataNames.Name(reader, declaring); + } +} diff --git a/src/src/ArchitectureTesting/APISurface.Members.cs b/src/src/ArchitectureTesting/APISurface.Members.cs new file mode 100644 index 0000000..d24c816 --- /dev/null +++ b/src/src/ArchitectureTesting/APISurface.Members.cs @@ -0,0 +1,130 @@ +using System.Reflection; +using System.Reflection.Metadata; + +namespace Purview.Architecture.Testing; + +/// Reads members from metadata. +public static partial class APISurface +{ + /// Reads a type's public members. + /// The assembly's metadata. + /// The type definition. + /// The members. + /// + /// Property, event and operator accessors are represented by their property or event, so they are + /// not listed twice. Non-public members are outside the surface a consumer can call and are + /// skipped. + /// + static List ReadMembers(MetadataReader reader, TypeDefinition type) + { + List members = []; + + foreach (var handle in type.GetMethods()) + { + var method = reader.GetMethodDefinition(handle); + var attributes = method.Attributes; + var name = reader.GetString(method.Name); + var isConstructor = name == ".ctor"; + + if (!isConstructor && (attributes & MethodAttributes.SpecialName) != 0) + continue; + + if ((attributes & MethodAttributes.MemberAccessMask) != MethodAttributes.Public) + continue; + + var signature = method.DecodeSignature(APISignatureProvider.Instance, null); + + members.Add( + new APIMember( + name, + isConstructor ? APIMemberKind.Constructor : APIMemberKind.Method, + signature.ReturnType, + (attributes & MethodAttributes.Static) != 0, + ReadAttributes(reader, method.GetCustomAttributes()) + ) + { + Parameters = signature.ParameterTypes, + } + ); + } + + foreach (var handle in type.GetProperties()) + { + var property = reader.GetPropertyDefinition(handle); + var accessors = property.GetAccessors(); + + if (!IsPublic(reader, accessors.Getter) && !IsPublic(reader, accessors.Setter)) + continue; + + members.Add( + new APIMember( + reader.GetString(property.Name), + APIMemberKind.Property, + property.DecodeSignature(APISignatureProvider.Instance, null).ReturnType, + IsStatic(reader, accessors.Getter) || IsStatic(reader, accessors.Setter), + ReadAttributes(reader, property.GetCustomAttributes()) + ) + ); + } + + foreach (var handle in type.GetFields()) + { + var field = reader.GetFieldDefinition(handle); + var fieldName = reader.GetString(field.Name); + + // Every enum carries this synthetic instance field; it is not a declaration. + if (fieldName == "value__") + continue; + + if ((field.Attributes & FieldAttributes.FieldAccessMask) != FieldAttributes.Public) + continue; + + members.Add( + new APIMember( + fieldName, + APIMemberKind.Field, + field.DecodeSignature(APISignatureProvider.Instance, null), + (field.Attributes & FieldAttributes.Static) != 0, + ReadAttributes(reader, field.GetCustomAttributes()) + ) + ); + } + + foreach (var handle in type.GetEvents()) + { + var @event = reader.GetEventDefinition(handle); + var accessors = @event.GetAccessors(); + + if (!IsPublic(reader, accessors.Adder) && !IsPublic(reader, accessors.Remover)) + continue; + + members.Add( + new APIMember( + reader.GetString(@event.Name), + APIMemberKind.Event, + MetadataNames.Name(reader, @event.Type) ?? string.Empty, + IsStatic(reader, accessors.Adder) || IsStatic(reader, accessors.Remover), + ReadAttributes(reader, @event.GetCustomAttributes()) + ) + ); + } + + return members; + } + + /// Reports whether an accessor is public, treating an absent accessor as not public. + /// The assembly's metadata. + /// The accessor handle, which may be nil. + /// when the accessor is public. + static bool IsPublic(MetadataReader reader, MethodDefinitionHandle handle) => + !handle.IsNil + && (reader.GetMethodDefinition(handle).Attributes & MethodAttributes.MemberAccessMask) + == MethodAttributes.Public; + + /// Reports whether an accessor is static, treating an absent accessor as not static. + /// The assembly's metadata. + /// The accessor handle, which may be nil. + /// when the accessor is static. + static bool IsStatic(MetadataReader reader, MethodDefinitionHandle handle) => + !handle.IsNil && (reader.GetMethodDefinition(handle).Attributes & MethodAttributes.Static) != 0; +} diff --git a/src/src/ArchitectureTesting/APISurface.Types.cs b/src/src/ArchitectureTesting/APISurface.Types.cs new file mode 100644 index 0000000..7c377c9 --- /dev/null +++ b/src/src/ArchitectureTesting/APISurface.Types.cs @@ -0,0 +1,76 @@ +using System.Reflection; +using System.Reflection.Metadata; + +namespace Purview.Architecture.Testing; + +/// Reads type declarations from metadata. +public static partial class APISurface +{ + /// Reads one type definition. + /// The assembly's metadata. + /// The type definition. + /// The project the assembly belongs to. + /// The type, or for the module's synthetic type. + static APIType? ReadType(MetadataReader reader, TypeDefinitionHandle handle, string projectName) + { + var type = reader.GetTypeDefinition(handle); + var name = reader.GetString(type.Name); + + // holds assembly-level members, not a declaration. + if (name == "") + return null; + + var attributes = type.Attributes; + var visibility = attributes & TypeAttributes.VisibilityMask; + var isPublic = visibility is TypeAttributes.Public or TypeAttributes.NestedPublic; + var isNested = visibility is not (TypeAttributes.Public or TypeAttributes.NotPublic); + var baseType = MetadataNames.Name(reader, type.BaseType); + + return new APIType( + projectName, + type.Namespace.IsNil ? string.Empty : reader.GetString(type.Namespace), + APISignatureProvider.TrimArity(name), + MetadataNames.Definition(reader, handle), + KindOf(attributes, baseType), + isPublic, + (attributes & TypeAttributes.Abstract) != 0, + (attributes & TypeAttributes.Sealed) != 0, + isNested, + baseType, + ReadInterfaces(reader, type), + ReadMembers(reader, type), + ReadAttributes(reader, type.GetCustomAttributes()) + ); + } + + /// Classifies a type from its flags and base type. + /// The type's attributes. + /// The type's base type, when it has one. + /// The kind. + static APITypeKind KindOf(TypeAttributes attributes, string? baseType) + { + if ((attributes & TypeAttributes.Interface) != 0) + return APITypeKind.Interface; + + // A type with no base type is a module-level type, which is not a declaration. + return baseType switch + { + "System.Enum" => APITypeKind.Enum, + "System.ValueType" => APITypeKind.Struct, + "System.MulticastDelegate" => APITypeKind.Delegate, + _ => APITypeKind.Class, + }; + } + + /// Reads the interfaces a type implements. + /// The assembly's metadata. + /// The type definition. + /// The interface names. + static List ReadInterfaces(MetadataReader reader, TypeDefinition type) => + [ + .. type.GetInterfaceImplementations() + .Select(handle => reader.GetInterfaceImplementation(handle).Interface) + .Select(handle => MetadataNames.Name(reader, handle)) + .OfType(), + ]; +} diff --git a/src/src/ArchitectureTesting/APISurface.cs b/src/src/ArchitectureTesting/APISurface.cs new file mode 100644 index 0000000..8134b90 --- /dev/null +++ b/src/src/ArchitectureTesting/APISurface.cs @@ -0,0 +1,144 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace Purview.Architecture.Testing; + +/// +/// Reads the types and members a compiled assembly declares, by metadata only. +/// +/// +/// +/// No assembly is loaded or executed: reads the definition tables +/// directly, exactly as reads the reference table. The source files are +/// not needed, so a rule can police the API a consumer actually receives rather than what the +/// project happens to contain. +/// +/// +/// A type's own file path is deliberately absent: metadata records the namespace, not the folder, so +/// the namespace rule compares the declared namespace with the project's RootNamespace and +/// makes no claim about folders. +/// +/// +public static partial class APISurface +{ + /// Finds the assemblies to read, using the same roots and globs as the dependency check. + /// The repository root. + /// The policy's assembly rules. + /// The assembly file paths. + public static IReadOnlyList Discover(string repositoryRoot, AssemblyRules rules) => + AssemblyGraph.Discover(repositoryRoot, rules); + + /// Reads every project assembly among the given paths. + /// The candidate assembly file paths. + /// The project graph, used to attribute an assembly to a project. + /// The assemblies, keyed by project name. + public static IReadOnlyDictionary Load( + IEnumerable assemblyPaths, + IReadOnlyDictionary projects + ) + { + ArgumentNullException.ThrowIfNull(assemblyPaths); + ArgumentNullException.ThrowIfNull(projects); + + Dictionary assemblies = new(StringComparer.OrdinalIgnoreCase); + + foreach (var path in assemblyPaths) + { + var assembly = ReadAssembly(path, projects); + + if (assembly is null) + continue; + + // A repository scan can find the same project in Debug, Release and publish output. The + // newest build is the one the repository is working on, so it wins. + if ( + assemblies.TryGetValue(assembly.ProjectName, out var existing) + && existing.Path.Length > 0 + && File.GetLastWriteTimeUtc(existing.Path) >= File.GetLastWriteTimeUtc(assembly.Path) + ) + { + continue; + } + + assemblies[assembly.ProjectName] = assembly; + } + + return assemblies; + } + + /// Reads one assembly's declared types. + /// The assembly file path. + /// The types, or an empty list when the file is not a managed assembly. + public static IReadOnlyList Read(string assemblyPath) => ReadAssembly(assemblyPath, null)?.Types ?? []; + + /// Reads one assembly and attributes it to a project. + /// The assembly file path. + /// + /// The project graph. When , the assembly is read without attribution. + /// + /// + /// The assembly, or when the file is not managed or is not a project. + /// + public static APIAssembly? ReadAssembly(string assemblyPath, IReadOnlyDictionary? projects) + { + ArgumentException.ThrowIfNullOrWhiteSpace(assemblyPath); + + try + { + using var stream = File.OpenRead(assemblyPath); + using PEReader peReader = new(stream); + + // A native dependency in the output has no managed metadata. + if (!peReader.HasMetadata) + return null; + + var reader = peReader.GetMetadataReader(); + var assemblyName = reader.GetString(reader.GetAssemblyDefinition().Name); + + // Satellite resource assemblies declare no types worth policing. + if (assemblyName.EndsWith(".resources", StringComparison.OrdinalIgnoreCase)) + return null; + + var projectName = projects is null + ? string.Empty + : AssemblyAttribution.Resolve(assemblyName, reader, projects) ?? string.Empty; + + // Not a project assembly: a package, and packages are outside the policy. + if (projects is not null && projectName.Length == 0) + return null; + + List types = []; + foreach (var handle in reader.TypeDefinitions) + { + try + { + var type = ReadType(reader, handle, projectName); + + if (type is not null) + types.Add(type); + } + catch (BadImageFormatException exception) + { + throw new InvalidOperationException( + $"Reading '{MetadataNames.Definition(reader, handle)}' failed.", + exception + ); + } + } + + var attributes = ReadAttributes(reader, reader.GetAssemblyDefinition().GetCustomAttributes()); + + return new APIAssembly(projectName, assemblyName, attributes, types) { Path = assemblyPath }; + } + catch (BadImageFormatException) + { + // A non-PE file that happened to match the glob. + return null; + } + catch (IOException) + { + // A file being written by another build, or otherwise unreadable this run. + return null; + } + } +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicy.cs b/src/src/ArchitectureTesting/ArchitecturePolicy.cs index 29f6456..22c1380 100644 --- a/src/src/ArchitectureTesting/ArchitecturePolicy.cs +++ b/src/src/ArchitectureTesting/ArchitecturePolicy.cs @@ -44,6 +44,30 @@ public sealed class ArchitecturePolicy [JsonPropertyName("assemblyRules")] public AssemblyRules AssemblyRules { get; init; } = new(); + /// The type-level naming, shape, namespace and attribute rules. + [JsonPropertyName("typeRules")] + public TypeRules TypeRules { get; init; } = new(); + + /// The committed-API rules: surface compatibility and documentation. + [JsonPropertyName("apiRules")] + public APIRules APIRules { get; init; } = new(); + + /// The port rules: implementation and technology purity. + [JsonPropertyName("portRules")] + public PortRules PortRules { get; init; } = new(); + + /// The dependency ratchet. + [JsonPropertyName("dependencyBudget")] + public DependencyBudget DependencyBudget { get; init; } = new(); + + /// The policy's own consistency checks. + [JsonPropertyName("policyIntegrity")] + public PolicyIntegritySettings PolicyIntegrity { get; init; } = new(); + + /// How long a waiver may go unreviewed. + [JsonPropertyName("waiverPolicy")] + public WaiverPolicySettings WaiverPolicy { get; init; } = new(); + [JsonPropertyName("waivers")] public IReadOnlyList Waivers { get; init; } = []; @@ -80,14 +104,18 @@ public static ArchitecturePolicy Load(string? repositoryRoot = null) } /// Finds the repository root by walking up to the folder holding the policy. + /// + /// Where the walk starts. Defaults to the application's base directory, which is where a test + /// binary runs; a command-line tool starts from its working directory instead. + /// /// The repository root path. /// /// The test binary runs from bin/, and the depth of that path differs between a local build /// and CI, so the root is found by probing for a marker rather than by counting parents. /// - public static string RepositoryRoot() + public static string RepositoryRoot(string? startDirectory = null) { - DirectoryInfo? directory = new(AppContext.BaseDirectory); + DirectoryInfo? directory = new(startDirectory ?? AppContext.BaseDirectory); while (directory is not null) { if (File.Exists(Path.Combine(directory.FullName, RelativePath))) @@ -171,6 +199,14 @@ public sealed class Responsibility [JsonPropertyName("forbiddenPackagePrefixes")] public IReadOnlyList ForbiddenPackagePrefixes { get; init; } = []; + /// + /// What forbiddenPackage measures: own (default) checks the packages the project + /// declares or its own package references bring in, excluding packages explained by a permitted + /// project reference; closure checks the whole restore closure. + /// + [JsonPropertyName("forbiddenPackageScope")] + public string ForbiddenPackageScope { get; init; } = "own"; + /// /// Framework references that must not appear in this responsibility, such as /// Microsoft.AspNetCore.App in a portable layer. @@ -226,6 +262,28 @@ public sealed class AssemblyRules public IReadOnlyList AssemblyGlobs { get; init; } = ["*.dll"]; } +/// Settings for the policy's own consistency checks. +public sealed class PolicyIntegritySettings +{ + /// + /// Whether to prove each mustNotDependOn is enforceable: a prohibition is meaningless when + /// the permitted mayDependOn closure can reach the forbidden responsibility anyway. + /// + [JsonPropertyName("checkReachability")] + public bool CheckReachability { get; init; } +} + +/// How long a waiver may go unreviewed. +public sealed class WaiverPolicySettings +{ + /// + /// The maximum age, in days, of a waiver's reviewedOn date. Unset by default, so expiry is + /// opt-in; a policy that adopts it turns "reviewed" from a note into an obligation. + /// + [JsonPropertyName("maxAgeDays")] + public int? MaxAgeDays { get; init; } +} + /// A deliberate, reviewed departure from the policy for one project. /// /// An exception is how a rule stays enforceable in a repository that predates it. Suppressing a rule diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Budget.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Budget.cs new file mode 100644 index 0000000..4bd2bdb --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Budget.cs @@ -0,0 +1,95 @@ +namespace Purview.Architecture.Testing; + +/// The dependency-budget ratchet. +public sealed partial class ArchitecturePolicyEvaluator +{ + /// Projects that exceed, or no longer fill, their dependency budget. + /// The violations. + /// + /// The rule is deliberately not waivable: the budget is the record that keeps dependency growth + /// reviewable, so excusing it would make the policy unfalsifiable. Remove the reference, or change + /// the budget in the same reviewed commit. + /// + public IReadOnlyList DependencyBudgetRule() + { + if (!Policy.DependencyBudget.Enabled) + return []; + + List violations = []; + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in Policy.DependencyBudget.Entries) + { + if (!seen.Add(entry.Project)) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.DependencyBudgetRule, + "(policy)", + $"The dependency budget declares '{entry.Project}' more than once. Only one entry can apply; merge them." + ) + ); + continue; + } + + if (!Projects.TryGetValue(entry.Project, out var project) || project.IsTestProject) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.DependencyBudgetRule, + "(policy)", + $"The dependency budget names '{entry.Project}', which is not a production project. Remove the entry." + ) + ); + continue; + } + + var allowed = entry.References.ToHashSet(StringComparer.OrdinalIgnoreCase); + var actual = project.ProjectReferences.ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var added in actual.Except(allowed).Order(StringComparer.OrdinalIgnoreCase)) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.DependencyBudgetRule, + project.Name, + $"'{project.Name}' references '{added}', which is not in its dependency budget. Remove the " + + "reference, or add it to dependencyBudget in a reviewed policy change." + ) + ); + } + + if (!Policy.DependencyBudget.FailOnStale) + continue; + + foreach (var stale in allowed.Except(actual).Order(StringComparer.OrdinalIgnoreCase)) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.DependencyBudgetRule, + project.Name, + $"The dependency budget for '{project.Name}' still allows '{stale}', but the project no longer " + + "references it. Tighten the budget: a stale allowance hides the next dependency." + ) + ); + } + } + + return violations; + } + + /// The adoption rule identifiers enabled by the policy. + /// The identifiers, or an empty list when the budget is off. + public IReadOnlyList EnabledAdoptionRules() => + Policy.DependencyBudget.Enabled ? [ArchitectureRules.DependencyBudgetRule] : []; + + /// Evaluates one adoption rule by identifier. + /// The rule identifier. + /// The violations. + public IReadOnlyList EvaluateAdoptionRule(string rule) => + rule switch + { + ArchitectureRules.DependencyBudgetRule => DependencyBudgetRule(), + _ => [], + }; +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Contracts.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Contracts.cs new file mode 100644 index 0000000..356109c --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Contracts.cs @@ -0,0 +1,157 @@ +using System.Text.Json; + +namespace Purview.Architecture.Testing; + +/// The committed-API rules: surface compatibility and documentation. +public sealed partial class ArchitecturePolicyEvaluator +{ + /// Differences between the current public API and its committed baseline. + /// The violations. + /// + /// Additions are reported as well as removals: the baseline is the reviewed contract, so growing + /// it is a deliberate act, not an accident. A missing baseline is itself a finding, because an + /// API rule that silently checks nothing is worse than no rule. + /// + public IReadOnlyList APISurfaceCompatibility() + { + if (!Policy.APIRules.Enabled) + return []; + + var baselinePath = string.IsNullOrWhiteSpace(Policy.APIRules.Baseline) + ? APIBaseline.RelativePath + : Policy.APIRules.Baseline; + + if (Baseline is null) + { + return + [ + new ArchitectureViolation( + ArchitectureRules.APISurface, + "(repository)", + $"No API baseline was found at '{baselinePath}'. Run 'architests api capture', review the " + + "result and commit it, or disable apiRules." + ), + ]; + } + + var current = APIBaseline.Capture(APISurfaces, Policy); + + return Excuse( + APIBaselineComparer + .Compare(Baseline, current) + .Select(change => new ArchitectureViolation( + ArchitectureRules.APISurface, + change.Project, + change.Detail + )) + ); + } + + /// Public types and members that have no XML documentation. + /// The violations. + public IReadOnlyList APIDocumentation() + { + if (!Policy.APIRules.Enabled) + return []; + + List violations = []; + + foreach (var assembly in APISurfaces.Values) + { + if (!IsProductionProject(assembly.ProjectName)) + continue; + + if (!XMLDocumentation.Exists(assembly.Path) && Policy.APIRules.RequireTypeDocumentation) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.APIDocumentation, + assembly.ProjectName, + $"'{assembly.ProjectName}' ships no XML documentation beside its assembly. Set " + + "true, or turn off apiRules.requireTypeDocumentation." + ) + ); + continue; + } + + var documented = XMLDocumentation.Read(assembly.Path); + + foreach (var type in assembly.Types.Where(candidate => candidate.IsPublic)) + { + if (Policy.APIRules.RequireTypeDocumentation && !documented.Contains(XMLDocumentation.TypeId(type))) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.APIDocumentation, + assembly.ProjectName, + $"'{type.FullName}' is public and undocumented. Add a to it." + ) + ); + } + + if (!Policy.APIRules.RequireMemberDocumentation) + continue; + + foreach (var member in type.Members) + { + if (documented.Contains(XMLDocumentation.MemberId(type, member))) + continue; + + violations.Add( + new ArchitectureViolation( + ArchitectureRules.APIDocumentation, + assembly.ProjectName, + $"'{type.FullName}.{member.Name}' is public and undocumented. Add a to it." + ) + ); + } + } + } + + return Excuse(violations); + } + + /// The API rule identifiers enabled by the policy. + /// The identifiers, or an empty list when the group is off. + public IReadOnlyList EnabledAPIRules() => + Policy.APIRules.Enabled ? [ArchitectureRules.APISurface, ArchitectureRules.APIDocumentation] : []; + + /// Evaluates one API rule by identifier. + /// The rule identifier. + /// The violations, with waived rules excused. + public IReadOnlyList EvaluateAPIRule(string rule) => + rule switch + { + ArchitectureRules.APISurface => APISurfaceCompatibility(), + ArchitectureRules.APIDocumentation => APIDocumentation(), + _ => [], + }; + + /// Reads the configured baseline, treating an absent or malformed file as missing. + /// The repository root. + /// The policy. + /// The baseline, or . + static APIBaseline? ReadBaseline(string root, ArchitecturePolicy policy) + { + var relative = string.IsNullOrWhiteSpace(policy.APIRules.Baseline) + ? APIBaseline.RelativePath + : policy.APIRules.Baseline; + + try + { + return APIBaseline.Read(Path.Combine(root, relative)); + } + catch (FileNotFoundException) + { + return null; + } + catch (JsonException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.PolicyIntegrity.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.PolicyIntegrity.cs new file mode 100644 index 0000000..c8a6939 --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.PolicyIntegrity.cs @@ -0,0 +1,232 @@ +namespace Purview.Architecture.Testing; + +/// The policy's own consistency checks. +public sealed partial class ArchitecturePolicyEvaluator +{ + /// The policy's own consistency: unknown names, overlapping permissions and duplicates. + /// The violations. + /// + /// These are unambiguous policy bugs rather than architectural choices, so the rule always runs. + /// It is deliberately not waivable: a waiver excusing a broken policy would make the policy + /// unfalsifiable. Reachability is opt-in because it proves a prohibition cannot be bypassed. + /// + public IReadOnlyList PolicyIntegrity() + { + List violations = []; + + Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + foreach (var responsibility in Policy.Responsibilities) + { + if (!byName.TryAdd(responsibility.Name, responsibility)) + { + violations.Add( + PolicyViolation( + $"The policy declares the responsibility '{responsibility.Name}' more than once. " + + "Only the first declaration classifies a project, so the second is silently ignored." + ) + ); + } + } + + violations.AddRange(AliasIntegrity(byName)); + + foreach (var responsibility in Policy.Responsibilities) + { + violations.AddRange(MatrixIntegrity(responsibility, byName)); + } + + if (Policy.PolicyIntegrity.CheckReachability) + violations.AddRange(ReachabilityIntegrity(byName)); + + return violations; + } + + /// Checks that no alias steals a name or means two responsibilities. + /// The responsibilities, keyed by name. + /// The violations. + List AliasIntegrity(Dictionary byName) + { + List violations = []; + Dictionary aliases = new(StringComparer.OrdinalIgnoreCase); + + foreach (var responsibility in Policy.Responsibilities) + { + foreach (var alias in responsibility.Aliases.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (byName.TryGetValue(alias, out var owner)) + { + violations.Add( + PolicyViolation( + $"'{responsibility.Name}' declares the alias '{alias}', which is the responsibility name '{owner.Name}'. " + + "A name wins, so the alias can never classify a project." + ) + ); + continue; + } + + if (!aliases.TryAdd(alias, responsibility)) + { + violations.Add( + PolicyViolation( + $"The alias '{alias}' is declared by both '{aliases[alias].Name}' and '{responsibility.Name}', " + + "so classification would silently pick one." + ) + ); + } + } + } + + return violations; + } + + /// Checks one responsibility's dependency lists against the catalogue. + /// The responsibility. + /// The responsibilities, keyed by name. + /// The violations. + List MatrixIntegrity( + Responsibility responsibility, + Dictionary byName + ) + { + List violations = []; + + foreach (var unknown in responsibility.MayDependOn.Where(target => !byName.ContainsKey(target))) + { + violations.Add(UnknownTarget(responsibility, "mayDependOn", unknown, byName)); + } + + foreach (var unknown in responsibility.MustNotDependOn.Where(target => !byName.ContainsKey(target))) + { + violations.Add(UnknownTarget(responsibility, "mustNotDependOn", unknown, byName)); + } + + foreach ( + var overlap in responsibility.MayDependOn.Where(target => + responsibility.MustNotDependOn.Contains(target, StringComparer.OrdinalIgnoreCase) + ) + ) + { + violations.Add( + PolicyViolation( + $"'{responsibility.Name}' both permits and forbids '{overlap}'. " + + "A dependency cannot be both; remove it from one list." + ) + ); + } + + if ( + !string.Equals(responsibility.ForbiddenPackageScope, "own", StringComparison.OrdinalIgnoreCase) + && !string.Equals(responsibility.ForbiddenPackageScope, "closure", StringComparison.OrdinalIgnoreCase) + ) + { + violations.Add( + PolicyViolation( + $"'{responsibility.Name}' declares forbiddenPackageScope '{responsibility.ForbiddenPackageScope}', " + + "which is not a scope. Use 'own' or 'closure'." + ) + ); + } + + return violations; + } + + /// Builds the violation for a matrix entry naming no responsibility. + /// The responsibility that declares the entry. + /// The list the entry appears in. + /// The unknown name. + /// The responsibilities, keyed by name. + /// The violation. + static ArchitectureViolation UnknownTarget( + Responsibility responsibility, + string list, + string target, + Dictionary byName + ) => + PolicyViolation( + $"'{responsibility.Name}' {list} names '{target}', which is not a responsibility. " + + $"Known responsibilities: {Names(byName)}. " + + "The matrix compares responsibility names, not aliases or project names." + ); + + /// Proves that no permitted dependency path reaches a forbidden responsibility. + /// The responsibilities, keyed by name. + /// The violations. + List ReachabilityIntegrity(Dictionary byName) + { + List violations = []; + + foreach (var responsibility in Policy.Responsibilities) + { + foreach (var forbidden in responsibility.MustNotDependOn) + { + // A direct overlap is already reported; do not report the same bug twice. + if (responsibility.MayDependOn.Contains(forbidden, StringComparer.OrdinalIgnoreCase)) + continue; + + var path = FindPath(byName, responsibility.Name, forbidden); + + if (path is not null) + { + violations.Add( + PolicyViolation( + $"'{responsibility.Name}' mustNotDependOn '{forbidden}', but its permitted dependencies reach it: {path}. " + + "The prohibition is unenforceable until the edge that closes the path is removed." + ) + ); + } + } + } + + return violations; + } + + /// Builds a violation that belongs to the policy rather than to a project. + /// What is wrong and how to fix it. + /// The violation. + static ArchitectureViolation PolicyViolation(string message) => + new(ArchitectureRules.PolicyIntegrity, "(policy)", message); + + /// Renders the known responsibility names for a failure message. + /// The responsibilities, keyed by name. + /// The names. + static string Names(Dictionary byName) => + byName.Count == 0 ? "(none)" : string.Join(", ", byName.Keys); + + /// Finds the shortest permitted dependency path between two responsibilities. + /// The responsibilities, keyed by name. + /// The responsibility whose permissions are followed. + /// The responsibility the path must reach. + /// The path, or when it cannot be reached. + static string? FindPath(Dictionary byName, string start, string target) + { + Queue> queue = new(); + HashSet visited = new(StringComparer.OrdinalIgnoreCase) { start }; + queue.Enqueue([start]); + + while (queue.Count > 0) + { + var path = queue.Dequeue(); + + if (!byName.TryGetValue(path[^1], out var current)) + continue; + + foreach (var next in current.MayDependOn) + { + // An unknown name is reported on its own; following it would add noise. + if (!byName.ContainsKey(next)) + continue; + + if (string.Equals(next, target, StringComparison.OrdinalIgnoreCase)) + return string.Join(" -> ", path.Append(next)); + + if (!visited.Add(next)) + continue; + + queue.Enqueue([.. path, next]); + } + } + + return null; + } +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Ports.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Ports.cs new file mode 100644 index 0000000..7a7c7ac --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.Ports.cs @@ -0,0 +1,146 @@ +namespace Purview.Architecture.Testing; + +/// The port rules: implementation and technology purity. +public sealed partial class ArchitecturePolicyEvaluator +{ + /// Ports with no public implementation in the repository. + /// The violations. + /// + /// A port is an interface in one of : a contract the inner + /// layers own. An interface nobody implements is either dead or a boundary that was never + /// completed, so it is reported rather than left as decoration. + /// + public IReadOnlyList PortImplementation() + { + if (!Policy.PortRules.Enabled) + return []; + + List violations = []; + + foreach (var (project, port) in Ports()) + { + var implemented = APISurfaces + .Values.Where(assembly => IsProductionProject(assembly.ProjectName)) + .SelectMany(assembly => assembly.Types) + .Any(type => + type.IsPublic + && type.Kind != APITypeKind.Interface + && type.Interfaces.Contains(port.FullName, StringComparer.Ordinal) + ); + + if (implemented) + continue; + + var responsibility = Policy.Classify(project); + + violations.Add( + new ArchitectureViolation( + ArchitectureRules.PortImplementation, + project, + $"'{port.FullName}' is a port in {responsibility?.Name ?? project} with no public implementation in the repository. " + + "Implement it in an adapter, or remove it: an unimplemented contract is a boundary nobody crossed." + ) + ); + } + + return Excuse(violations); + } + + /// Ports that expose an infrastructure technology in their signatures. + /// The violations. + public IReadOnlyList PortPurity() + { + if (!Policy.PortRules.Enabled) + return []; + + List violations = []; + + foreach (var (project, port) in Ports()) + { + foreach (var member in port.Members) + { + var exposed = member.Parameters.Prepend(member.TypeName); + var leak = exposed + .Select(type => + ( + Type: type, + Prefix: Policy.PortRules.ForbiddenTypePrefixes.FirstOrDefault(prefix => + !string.IsNullOrEmpty(prefix) + && type.Contains(prefix, StringComparison.OrdinalIgnoreCase) + ) + ) + ) + .FirstOrDefault(candidate => candidate.Prefix is not null); + + if (leak.Prefix is null) + continue; + + violations.Add( + new ArchitectureViolation( + ArchitectureRules.PortPurity, + project, + $"'{port.FullName}.{member.Name}' exposes '{leak.Type}', which names the infrastructure " + + $"prefix '{leak.Prefix}'. A port must be technology-neutral; move the technology into the adapter." + ) + ); + } + } + + return Excuse(violations); + } + + /// The port rule identifiers enabled by the policy. + /// The identifiers, or an empty list when the group is off. + public IReadOnlyList EnabledPortRules() => + Policy.PortRules.Enabled ? [ArchitectureRules.PortImplementation, ArchitectureRules.PortPurity] : []; + + /// Evaluates one port rule by identifier. + /// The rule identifier. + /// The violations, with waived rules excused. + public IReadOnlyList EvaluatePortRule(string rule) => + rule switch + { + ArchitectureRules.PortImplementation => PortImplementation(), + ArchitectureRules.PortPurity => PortPurity(), + _ => [], + }; + + /// Enumerates the interfaces the policy treats as ports. + /// The declaring project and the interface. + IEnumerable<(string Project, APIType Type)> Ports() + { + if (!Policy.PortRules.Enabled) + yield break; + + foreach (var (project, assembly) in APISurfaces) + { + if (!IsProductionProject(project)) + continue; + + var responsibility = Policy.Classify(project); + + if ( + responsibility is null + || !Policy.PortRules.Categories.Contains(responsibility.Category, StringComparer.OrdinalIgnoreCase) + ) + { + continue; + } + + foreach ( + var type in assembly.Types.Where(candidate => + candidate.IsPublic && candidate.Kind == APITypeKind.Interface + ) + ) + { + yield return (project, type); + } + } + } + + /// Reports whether a project is production code. + /// The project name. + /// when the project exists and is not a test project. + bool IsProductionProject(string projectName) => + Projects.TryGetValue(projectName, out var project) && !project.IsTestProject; +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Attributes.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Attributes.cs new file mode 100644 index 0000000..e5b4277 --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Attributes.cs @@ -0,0 +1,150 @@ +namespace Purview.Architecture.Testing; + +/// The attribute rule. +public sealed partial class ArchitecturePolicyEvaluator +{ + /// Types and members whose attributes are missing, forbidden or misleading. + /// The violations. + public IReadOnlyList AttributePolicy() + { + List violations = []; + var settings = Policy.TypeRules; + + foreach (var (project, type) in GovernedTypes()) + { + foreach (var required in settings.RequiredAttributes) + { + if (!HasAttribute(type.Attributes, required)) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.AttributePolicy, + project, + $"'{type.FullName}' is missing the required attribute '{required}'." + ) + ); + } + } + + foreach (var forbidden in settings.ForbiddenAttributes) + { + if (HasAttribute(type.Attributes, forbidden)) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.AttributePolicy, + project, + $"'{type.FullName}' carries the forbidden attribute '{forbidden}'." + ) + ); + } + } + + if (settings.AllowObsoleteWithoutMessage) + continue; + + AddObsolete(violations, project, $"type '{type.FullName}'", type.Attributes); + + foreach (var member in type.Members) + { + AddObsolete(violations, project, $"member '{type.FullName}.{member.Name}'", member.Attributes); + } + } + + if (settings.ForbidInternalsVisibleToProduction) + violations.AddRange(InternalsVisibleToProduction()); + + return Excuse(violations); + } + + /// Reports friend assemblies that expose internals to production code. + /// The violations. + IEnumerable InternalsVisibleToProduction() + { + foreach (var (project, assembly) in APISurfaces) + { + if (!Governs(project)) + continue; + + foreach ( + var attribute in assembly.Attributes.Where(candidate => + candidate.TypeName == "System.Runtime.CompilerServices.InternalsVisibleToAttribute" + ) + ) + { + // A strong-named friend is written "Name, PublicKey=..."; the name is what identifies it. + var friend = attribute.StringArgument?.Split(',')[0].Trim(); + + if (string.IsNullOrEmpty(friend) || !IsProductionAssembly(friend)) + continue; + + yield return TypeRuleViolation( + ArchitectureRules.AttributePolicy, + project, + $"assembly '{assembly.AssemblyName}' makes its internals visible to the production assembly " + + $"'{friend}'. Expose a public abstraction instead, or restrict the friend to a test project." + ); + } + } + } + + /// Reports whether an assembly name belongs to a production project. + /// The friend assembly's simple name. + /// when it maps to a production project. + bool IsProductionAssembly(string assemblyName) + { + var projectName = + APISurfaces + .Values.FirstOrDefault(assembly => + string.Equals(assembly.AssemblyName, assemblyName, StringComparison.OrdinalIgnoreCase) + ) + ?.ProjectName + ?? Assemblies + .Values.FirstOrDefault(assembly => + string.Equals(assembly.AssemblyName, assemblyName, StringComparison.OrdinalIgnoreCase) + ) + ?.ProjectName + ?? Projects + .Values.FirstOrDefault(project => + string.Equals(project.Name, assemblyName, StringComparison.OrdinalIgnoreCase) + ) + ?.Name; + + return projectName is not null && Projects.TryGetValue(projectName, out var project) && !project.IsTestProject; + } + + /// Adds a violation for an [Obsolete] without a message. + /// The accumulating violations. + /// The project. + /// The type or member the attribute is applied to. + /// The attributes applied to it. + static void AddObsolete( + List violations, + string project, + string subject, + IReadOnlyList attributes + ) + { + foreach ( + var attribute in attributes.Where(candidate => + candidate.TypeName == "System.ObsoleteAttribute" && string.IsNullOrWhiteSpace(candidate.StringArgument) + ) + ) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.AttributePolicy, + project, + $"{subject} is marked [Obsolete] without a message. Say what to use instead, so the caller can act." + ) + ); + } + } + + /// Reports whether a type carries an attribute. + /// The attributes applied to the type. + /// The namespace-qualified attribute type name. + /// when the attribute is present. + static bool HasAttribute(IReadOnlyList attributes, string typeName) => + attributes.Any(attribute => string.Equals(attribute.TypeName, typeName, StringComparison.Ordinal)); +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Helpers.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Helpers.cs new file mode 100644 index 0000000..15168dd --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Helpers.cs @@ -0,0 +1,94 @@ +namespace Purview.Architecture.Testing; + +/// Shared selection and predicate helpers for the type-level rules. +public sealed partial class ArchitecturePolicyEvaluator +{ + /// Enumerates the public types the policy governs. + /// The project and type pairs, in assembly order. + IEnumerable<(string Project, APIType Type)> GovernedTypes() + { + if (!Policy.TypeRules.Enabled) + yield break; + + foreach (var (project, assembly) in APISurfaces) + { + if (!Governs(project)) + continue; + + foreach (var type in assembly.Types) + { + if (!type.IsPublic) + continue; + + if (Policy.TypeRules.ExcludeGeneratedCode && type.IsGeneratedCode) + continue; + + if ( + Policy.TypeRules.ExcludeNamespacePrefixes.Any(prefix => + !string.IsNullOrEmpty(prefix) && type.Namespace.StartsWith(prefix, StringComparison.Ordinal) + ) + ) + { + continue; + } + + yield return (project, type); + } + } + } + + /// Reports whether the type rules govern a project. + /// The project name. + /// when the project is production code in a governed category. + bool Governs(string projectName) + { + if (!Policy.TypeRules.Enabled) + return false; + + if (!Projects.TryGetValue(projectName, out var project) || project.IsTestProject) + return false; + + var responsibility = Policy.Classify(projectName); + + if (responsibility is null) + return false; + + var categories = Policy.TypeRules.AppliesTo.Categories; + + return categories.Count == 0 || categories.Contains(responsibility.Category, StringComparer.OrdinalIgnoreCase); + } + + /// Builds a type-rule violation. + /// The rule identifier. + /// The project that declares the type. + /// What is wrong and how to fix it. + /// The violation. + static ArchitectureViolation TypeRuleViolation(string rule, string project, string message) => + new(rule, project, message); + + /// Reports whether a name follows the interface convention. + /// The type's simple name. + /// when the name starts with I and an uppercase letter. + static bool IsInterfaceName(string name) => name.Length > 1 && name[0] == 'I' && char.IsUpper(name[1]); + + /// Reports whether a declared type is a mutable collection implementation. + /// The namespace-qualified type name. + /// for List<T> and Dictionary<TKey, TValue>. + static bool IsMutableCollection(string typeName) => + typeName.StartsWith("System.Collections.Generic.List<", StringComparison.Ordinal) + || typeName.StartsWith("System.Collections.Generic.Dictionary<", StringComparison.Ordinal); + + /// Reports whether a type is a utility class that should be static. + /// The type. + /// when it has only static members and no public constructor. + static bool IsUtilityClass(APIType type) + { + var hasInstanceMember = type.Members.Any(member => + member.Kind != APIMemberKind.Constructor && !member.IsStatic + ); + var hasStaticMember = type.Members.Any(member => member.Kind != APIMemberKind.Constructor && member.IsStatic); + var hasPublicConstructor = type.Members.Any(member => member.Kind == APIMemberKind.Constructor); + + return !hasInstanceMember && hasStaticMember && !hasPublicConstructor; + } +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Namespace.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Namespace.cs new file mode 100644 index 0000000..89dce04 --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.Namespace.cs @@ -0,0 +1,75 @@ +namespace Purview.Architecture.Testing; + +/// The namespace rule and the type-rule dispatch. +public sealed partial class ArchitecturePolicyEvaluator +{ + /// Types declared outside their project's root namespace. + /// The violations. + /// + /// Metadata records the namespace, not the folder, so this checks the declared root and makes no + /// claim about where the file sits. + /// + public IReadOnlyList TypeNamespace() + { + List violations = []; + + foreach (var (project, type) in GovernedTypes()) + { + if (!Projects.TryGetValue(project, out var node)) + continue; + + var root = node.RootNamespace; + + if (string.IsNullOrWhiteSpace(root)) + continue; + + if ( + string.Equals(type.Namespace, root, StringComparison.Ordinal) + || type.Namespace.StartsWith(root + ".", StringComparison.Ordinal) + ) + { + continue; + } + + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeNamespace, + project, + $"'{type.FullName}' is declared in namespace '{type.Namespace}', which is outside the project's " + + $"root namespace '{root}'. Set RootNamespace on '{project}', or move the type under it." + ) + ); + } + + return Excuse(violations); + } + + /// The type-level rule identifiers enabled by the policy. + /// + /// The identifiers, or an empty list when type rules are off, so a disabled group produces no + /// test rather than a test that checks nothing. + /// + public IReadOnlyList EnabledTypeRules() => + Policy.TypeRules.Enabled + ? + [ + ArchitectureRules.TypeNaming, + ArchitectureRules.TypeShape, + ArchitectureRules.TypeNamespace, + ArchitectureRules.AttributePolicy, + ] + : []; + + /// Evaluates one type-level rule by identifier. + /// The rule identifier. + /// The violations, with waived rules excused. + public IReadOnlyList EvaluateTypeRule(string rule) => + rule switch + { + ArchitectureRules.TypeNaming => TypeNaming(), + ArchitectureRules.TypeShape => TypeShape(), + ArchitectureRules.TypeNamespace => TypeNamespace(), + ArchitectureRules.AttributePolicy => AttributePolicy(), + _ => [], + }; +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.cs new file mode 100644 index 0000000..00dd819 --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.TypeRules.cs @@ -0,0 +1,158 @@ +namespace Purview.Architecture.Testing; + +/// The type-level naming and shape rules. +public sealed partial class ArchitecturePolicyEvaluator +{ + /// Types whose names do not follow the conventions their kind implies. + /// The violations. + public IReadOnlyList TypeNaming() + { + List violations = []; + + foreach (var (project, type) in GovernedTypes()) + { + if (type.Kind == APITypeKind.Interface && !IsInterfaceName(type.Name)) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeNaming, + project, + $"'{type.FullName}' is an interface whose name does not start with 'I'. " + + $"Rename it to 'I{type.Name}' so the kind is visible at every use." + ) + ); + } + + if (type.IsAttribute && !type.Name.EndsWith("Attribute", StringComparison.Ordinal)) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeNaming, + project, + $"'{type.FullName}' derives from System.Attribute but does not end with 'Attribute'. " + + $"Rename it to '{type.Name}Attribute'." + ) + ); + } + + if (type.IsException && !type.Name.EndsWith("Exception", StringComparison.Ordinal)) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeNaming, + project, + $"'{type.FullName}' derives from an exception but does not end with 'Exception'. " + + $"Rename it to '{type.Name}Exception'." + ) + ); + } + + var suffix = Policy.TypeRules.DiscouragedTypeSuffixes.FirstOrDefault(candidate => + candidate.Length > 0 && type.Name.EndsWith(candidate, StringComparison.OrdinalIgnoreCase) + ); + + if (suffix is not null) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeNaming, + project, + $"'{type.FullName}' ends with the discouraged suffix '{suffix}', which names a mechanism " + + "rather than a responsibility. Name what the type is for, not how it is implemented." + ) + ); + } + } + + return Excuse(violations); + } + + /// Types whose declared shape weakens the boundary they sit on. + /// The violations. + public IReadOnlyList TypeShape() + { + List violations = []; + + foreach (var (project, type) in GovernedTypes()) + { + if ( + Policy.TypeRules.SealConcreteClasses + && type.Kind == APITypeKind.Class + && !type.IsAbstract + && !type.IsSealed + ) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeShape, + project, + $"'{type.FullName}' is a public, inheritable class. Seal it, or make it abstract if it is " + + "meant to be a base type: an unsealed class is a public extension point nobody declared." + ) + ); + } + + if ( + type.Kind == APITypeKind.Class + && type.Name.EndsWith("Exception", StringComparison.Ordinal) + && !type.IsException + ) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeShape, + project, + $"'{type.FullName}' is named like an exception but does not derive from System.Exception. " + + "Derive from Exception, or rename it." + ) + ); + } + + foreach (var field in type.Members.Where(member => member.Kind == APIMemberKind.Field && !member.IsStatic)) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeShape, + project, + $"'{type.FullName}' exposes the public field '{field.Name}'. A field cannot validate or evolve " + + "without a breaking change; expose a property instead." + ) + ); + } + + if (Policy.TypeRules.RequireReadOnlyCollections) + { + foreach ( + var member in type.Members.Where(member => + (member.Kind is APIMemberKind.Property or APIMemberKind.Method) + && IsMutableCollection(member.TypeName) + ) + ) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeShape, + project, + $"'{type.FullName}.{member.Name}' exposes '{member.TypeName}'. Return IReadOnlyList or " + + "IReadOnlyDictionary so a caller cannot mutate the owner's state." + ) + ); + } + } + + if (type.Kind == APITypeKind.Class && !type.IsStatic && !type.IsAbstract && IsUtilityClass(type)) + { + violations.Add( + TypeRuleViolation( + ArchitectureRules.TypeShape, + project, + $"'{type.FullName}' has only static members and no public constructor. Declare it static so " + + "it cannot be instantiated by accident." + ) + ); + } + } + + return Excuse(violations); + } +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs index 0aa3831..29e7f3f 100644 --- a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs @@ -8,9 +8,10 @@ namespace Purview.Architecture.Testing; /// directly, and coupling them to a test base class would make them reachable only through one. /// is a thin wrapper over this. /// -public sealed class ArchitecturePolicyEvaluator +public sealed partial class ArchitecturePolicyEvaluator { readonly IReadOnlyList _additionalRules; + readonly DateOnly _today; /// Creates an evaluator over an explicit policy and project graph. /// The policy to apply. @@ -23,11 +24,19 @@ public ArchitecturePolicyEvaluator(ArchitecturePolicy policy, IReadOnlyDictionar /// The projects to evaluate, keyed by name. /// The compiled assemblies to evaluate, keyed by project name. /// Rules a repository adds to the standard set. + /// + /// The date waiver expiry is measured from. Defaults to today in UTC, so a test can pin the clock. + /// + /// The declared types of each compiled assembly, keyed by project name. + /// The committed API baseline, when one was found. public ArchitecturePolicyEvaluator( ArchitecturePolicy policy, IReadOnlyDictionary projects, IReadOnlyDictionary assemblies, - IReadOnlyList? additionalRules = null + IReadOnlyList? additionalRules = null, + DateOnly? today = null, + IReadOnlyDictionary? apiSurfaces = null, + APIBaseline? apiBaseline = null ) { ArgumentNullException.ThrowIfNull(policy); @@ -37,16 +46,21 @@ public ArchitecturePolicyEvaluator( Policy = policy; Projects = projects; Assemblies = assemblies; + APISurfaces = apiSurfaces ?? new Dictionary(); + Baseline = apiBaseline; _additionalRules = additionalRules ?? []; + _today = today ?? DateOnly.FromDateTime(DateTime.UtcNow); } /// Creates an evaluator for a repository on disk. /// The repository root, or to probe upward for one. /// Rules a repository adds to the standard set. + /// The date waiver expiry is measured from, or for today. /// The evaluator. public static ArchitecturePolicyEvaluator ForRepository( string? repositoryRoot = null, - IReadOnlyList? additionalRules = null + IReadOnlyList? additionalRules = null, + DateOnly? today = null ) { var root = repositoryRoot ?? ArchitecturePolicy.RepositoryRoot(); @@ -55,11 +69,30 @@ public static ArchitecturePolicyEvaluator ForRepository( var projects = ProjectGraph.Load(root, policy.SourceRoots); - var assemblies = policy.AssemblyRules.Enabled - ? AssemblyGraph.Load(AssemblyGraph.Discover(root, policy.AssemblyRules), projects) + // Every reader scans the same assemblies, so the glob is walked once. + var needsAssemblies = policy.AssemblyRules.Enabled; + var needsSurfaces = policy.TypeRules.Enabled || policy.APIRules.Enabled || policy.PortRules.Enabled; + var assemblyPaths = needsAssemblies || needsSurfaces ? AssemblyGraph.Discover(root, policy.AssemblyRules) : []; + + var assemblies = needsAssemblies + ? AssemblyGraph.Load(assemblyPaths, projects) : new Dictionary(); - return new ArchitecturePolicyEvaluator(policy, projects, assemblies, additionalRules); + var apiSurfaces = needsSurfaces + ? APISurface.Load(assemblyPaths, projects) + : new Dictionary(); + + var apiBaseline = policy.APIRules.Enabled ? ReadBaseline(root, policy) : null; + + return new ArchitecturePolicyEvaluator( + policy, + projects, + assemblies, + additionalRules, + today, + apiSurfaces, + apiBaseline + ); } /// The policy being applied. @@ -71,6 +104,12 @@ public static ArchitecturePolicyEvaluator ForRepository( /// The compiled assemblies, keyed by project name. public IReadOnlyDictionary Assemblies { get; } + /// The declared types of each compiled assembly, keyed by project name. + public IReadOnlyDictionary APISurfaces { get; } + + /// The committed API baseline, or when it is absent or unreadable. + public APIBaseline? Baseline { get; } + /// The production projects, excluding test projects. public IEnumerable ProductionProjects => Projects.Values.Where(project => !project.IsTestProject); @@ -78,17 +117,27 @@ public static ArchitecturePolicyEvaluator ForRepository( /// Every violation found, in rule order. public IReadOnlyList EvaluateAll() => [ + .. PolicyIntegrity(), .. GraphDiscovery(), .. Naming(), .. DiscouragedNames(), .. DependencyMatrix(), .. AssemblyDependencyMatrix(), + .. TypeNaming(), + .. TypeShape(), + .. TypeNamespace(), + .. AttributePolicy(), + .. APISurfaceCompatibility(), + .. APIDocumentation(), + .. PortImplementation(), + .. PortPurity(), .. SiblingIndependence(), .. CompositionBoundary(), .. ForbiddenPackages(), .. ForbiddenFrameworkReferences(), .. Cycles(), .. TestReferences(), + .. DependencyBudgetRule(), .. WaiverIntegrity(), .. EvaluateAdditionalRules(), ]; @@ -357,8 +406,13 @@ public IReadOnlyList CompositionBoundary() => )) ); - /// Packages matching a forbidden prefix for their responsibility. + /// Packages a responsibility owns that match a forbidden prefix for it. /// The violations. + /// + /// Ownership excludes packages a permitted project reference explains, so a host is not blamed + /// for its adapter's technology. Set forbiddenPackageScope to closure for the + /// stricter compile-graph reading. + /// public IReadOnlyList ForbiddenPackages() { List violations = []; @@ -369,30 +423,135 @@ public IReadOnlyList ForbiddenPackages() if (responsibility is null) continue; - foreach (var package in project.PackageReferences) + var closureScope = string.Equals( + responsibility.ForbiddenPackageScope, + "closure", + StringComparison.OrdinalIgnoreCase + ); + + var packages = closureScope + ? project + .PackageReferences.Concat(project.ResolvedPackages) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + : OwnedPackages(project); + + foreach (var prefix in responsibility.ForbiddenPackagePrefixes) { - var forbidden = responsibility.ForbiddenPackagePrefixes.FirstOrDefault(prefix => - package.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - ); + if (string.IsNullOrWhiteSpace(prefix)) + continue; - if (forbidden is not null) - { - violations.Add( - new ArchitectureViolation( - ArchitectureRules.ForbiddenPackage, - project.Name, - $"'{project.Name}' ({responsibility.Name}) references package '{package}', which matches the forbidden " - + $"prefix '{forbidden}' for {responsibility.Name}. {responsibility.Purpose} " - + "Move the dependency to the responsibility that owns that technology." - ) - ); - } + // One finding per technology: report the shortest matching package, not every + // Microsoft.EntityFrameworkCore.* assembly in the graph. + var matches = packages + .Where(package => package.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .OrderBy(package => package.Length) + .ThenBy(package => package, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (matches.Length == 0) + continue; + + var package = matches[0]; + var examples = matches.Skip(1).Take(3).ToArray(); + var more = matches.Length - 1 - examples.Length; + var related = + matches.Length == 1 + ? string.Empty + : $" (and {matches.Length - 1} related package(s): {string.Join(", ", examples)}" + + (more > 0 ? $", and {more} more)" : ")"); + + violations.Add( + new ArchitectureViolation( + ArchitectureRules.ForbiddenPackage, + project.Name, + closureScope + ? $"'{project.Name}' ({responsibility.Name}) resolves package '{package}'{related} in its restore " + + $"closure, which matches the forbidden prefix '{prefix}'. The closure includes packages " + + "inherited from project references." + : $"'{project.Name}' ({responsibility.Name}) declares or brings in package '{package}'{related} " + + $"through its own package references, which matches the forbidden prefix '{prefix}'. " + + "No permitted project reference explains it." + ) + ); } } return Excuse(violations); } + /// The packages a project owns, after subtracting its permitted project references. + /// The project. + /// The package identifiers, sorted. + /// + /// Ownership is the closure of the packages the project declares itself, plus anything in the + /// restore graph that no permitted project reference explains. It is computed per target + /// framework, because a conditional reference exists in one target and not another, and the + /// results are unioned: a package unexplained in any target is owned. + /// + public IReadOnlyList OwnedPackages(ProjectNode project) + { + ArgumentNullException.ThrowIfNull(project); + + if (project.PackageTargets.Count == 0) + return + [ + .. project + .PackageReferences.Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase), + ]; + + HashSet owned = new(project.PackageReferences, StringComparer.OrdinalIgnoreCase); + + var permitted = project + .ProjectReferences.Where(reference => IsPermittedReference(project.Name, reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var target in project.PackageTargets) + { + // An incomplete restore cannot prove what a permitted reference brought in, so only the + // packages the project owns outright are counted. False positives are worse than a gap. + if (permitted.Any(reference => !target.ThroughReferences.ContainsKey(reference))) + { + owned.UnionWith(target.Owned); + continue; + } + + HashSet explained = new(StringComparer.OrdinalIgnoreCase); + + foreach (var reference in permitted) + explained.UnionWith(target.ThroughReferences[reference]); + + owned.UnionWith(target.Owned); + owned.UnionWith(target.Resolved.Where(package => !explained.Contains(package))); + } + + return [.. owned.Order(StringComparer.OrdinalIgnoreCase)]; + } + + /// Reports whether the matrix permits one project to reference another. + /// The referencing project. + /// The referenced project. + /// when the reference is permitted. + bool IsPermittedReference(string sourceName, string referenceName) + { + var source = Policy.Classify(sourceName); + var target = Policy.Classify(referenceName); + + if (source is null || target is null) + return false; + + if (string.Equals(source.Name, target.Name, StringComparison.OrdinalIgnoreCase)) + return true; + + if (source.MustNotDependOn.Contains(target.Name, StringComparer.OrdinalIgnoreCase)) + return false; + + // Silence is not permission: the target must be on the source's allowlist, not + // merely absent from its prohibitions. + return source.MayDependOn.Contains(target.Name, StringComparer.OrdinalIgnoreCase); + } + /// Framework references matching a forbidden prefix for their responsibility. /// The violations. public IReadOnlyList ForbiddenFrameworkReferences() @@ -510,6 +669,22 @@ public IReadOnlyList WaiverIntegrity() ); } + foreach ( + var unwaivable in waiver.Rules.Where(rule => + knownRules.Contains(rule) && !ArchitectureRules.IsWaivable(rule) + ) + ) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.WaiverIntegrity, + waiver.Project, + $"The waiver for '{waiver.Project}' excuses '{unwaivable}', which cannot be waived: it guards the policy itself. " + + "Remove it from the waiver." + ) + ); + } + if (waiver.MapsTo.Length > 0 && Policy.Classify(waiver.Project) is null) { violations.Add( @@ -522,6 +697,18 @@ public IReadOnlyList WaiverIntegrity() } } + // A waiver nobody revisits is how a policy quietly stops being true. + foreach (var review in WaiverReviewer.Review(Policy, _today).Where(review => review.IsStale)) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.WaiverIntegrity, + review.Waiver.Project, + $"The waiver for '{review.Waiver.Project}' {review.Reason}" + ) + ); + } + return violations; } @@ -530,7 +717,7 @@ public IReadOnlyList WaiverIntegrity() public IReadOnlyList EvaluateAdditionalRules() => [ .. _additionalRules.SelectMany(rule => - Excuse(rule.Evaluate(new ArchitectureContext(Policy, Projects, Assemblies))) + Excuse(rule.Evaluate(new ArchitectureContext(Policy, Projects, Assemblies, APISurfaces, Baseline))) ), ]; @@ -542,7 +729,9 @@ public IReadOnlyList EvaluateAdditionalRule(string rule) .. _additionalRules .Where(candidate => string.Equals(candidate.Rule, rule, StringComparison.OrdinalIgnoreCase)) .SelectMany(candidate => - Excuse(candidate.Evaluate(new ArchitectureContext(Policy, Projects, Assemblies))) + Excuse( + candidate.Evaluate(new ArchitectureContext(Policy, Projects, Assemblies, APISurfaces, Baseline)) + ) ), ]; @@ -562,11 +751,16 @@ public static string Report(IReadOnlyList violations) ); } - /// Drops violations a waiver excuses, so any rule id can be waived per project. + /// Drops violations a waiver excuses, so any waivable rule can be waived per project. /// The violations to filter. /// The violations that are not excused. IReadOnlyList Excuse(IEnumerable violations) => - [.. violations.Where(violation => Policy.WaiverFor(violation.Project)?.Excuses(violation.Rule) != true)]; + [ + .. violations.Where(violation => + !ArchitectureRules.IsWaivable(violation.Rule) + || Policy.WaiverFor(violation.Project)?.Excuses(violation.Rule) != true + ), + ]; /// Reports whether a project belongs to a category. /// The project name. diff --git a/src/src/ArchitectureTesting/ArchitectureRuleDescriptor.cs b/src/src/ArchitectureTesting/ArchitectureRuleDescriptor.cs new file mode 100644 index 0000000..ac37d10 --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitectureRuleDescriptor.cs @@ -0,0 +1,26 @@ +namespace Purview.Architecture.Testing; + +/// Describes one rule in the standard set. +/// The rule identifier, used by the test name, SkipRule and waivers. +/// A short human-readable title. +/// What the rule catches, in one sentence. +/// What the rule reads: the policy, the project graph or the assemblies. +/// Whether a policy waiver may excuse the rule. +/// +/// The opt-in policy group that enables the rule, or empty when the rule is always on. +/// +/// The documentation heading a failure can point at. +/// +/// The catalogue exists so the rule set has one authority. is +/// derived from it, the documentation test asserts every identifier appears in the docs and skills, +/// and answers from it rather than from a second list. +/// +public sealed record ArchitectureRuleDescriptor( + string Rule, + string Title, + string Summary, + string DataSource, + bool Waivable, + string Group = "", + string DocumentationAnchor = "" +); diff --git a/src/src/ArchitectureTesting/ArchitectureTesting.csproj b/src/src/ArchitectureTesting/ArchitectureTesting.csproj index d6d1fd0..a3c3f44 100644 --- a/src/src/ArchitectureTesting/ArchitectureTesting.csproj +++ b/src/src/ArchitectureTesting/ArchitectureTesting.csproj @@ -10,7 +10,11 @@ The policy reader and evaluator are deliberately free of MSBuild and test-framework types, so the rules can be exercised directly. Only PurviewArchitectureTests touches TUnit. --> - net10.0 + + net10.0;net11.0 + + + diff --git a/src/tests/ArchitectureTesting.CLI.UnitTests/CommandLineTests.cs b/src/tests/ArchitectureTesting.CLI.UnitTests/CommandLineTests.cs new file mode 100644 index 0000000..0df1d6a --- /dev/null +++ b/src/tests/ArchitectureTesting.CLI.UnitTests/CommandLineTests.cs @@ -0,0 +1,153 @@ +using Purview.Architecture.CLI; + +namespace Purview.Architecture.Testing.CLI.UnitTests; + +/// Exercises the parser and the validate command. +public sealed class CommandLineTests +{ + [Test] + public async Task Run_GivenNoArguments_WritesUsageAndSucceeds() + { + // Act + var (exitCode, output, _) = ToolFixture.Run(); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("Usage:"); + await Assert.That(output).Contains("validate"); + } + + [Test] + public async Task Run_GivenHelp_WritesUsageAndSucceeds() + { + // Act + var (exitCode, output, _) = ToolFixture.Run("--help"); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("architests [options]"); + } + + [Test] + public async Task Run_GivenVersion_WritesTheVersionAndSucceeds() + { + // Act + var (exitCode, output, _) = ToolFixture.Run("--version"); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output.Trim()).IsEqualTo(CommandLine.Version); + } + + [Test] + public async Task Run_GivenAnUnknownCommand_ReturnsUsageError() + { + // Act + var (exitCode, _, error) = ToolFixture.Run("frobnicate"); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.UsageError); + await Assert.That(error).Contains("Unknown command 'frobnicate'"); + } + + [Test] + public async Task Run_GivenValidateAndAValidPolicy_Succeeds() + { + // Arrange + var root = ToolFixture.CreateRepository(ToolFixture.ValidPolicy); + + try + { + // Act + var (exitCode, output, error) = ToolFixture.Run("validate", "--repository", root); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("Policy is valid"); + await Assert.That(error).IsEmpty(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Run_GivenValidateAndASchemaBreach_ReturnsFindings() + { + // Arrange + var root = ToolFixture.CreateRepository(ToolFixture.SchemaBreach); + + try + { + // Act + var (exitCode, _, error) = ToolFixture.Run("validate", "--repository", root); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Findings); + await Assert.That(error).Contains("does not match its schema"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Run_GivenValidateAndAnIntegrityBreach_ReturnsFindingsThatNameTheCorrection() + { + // Arrange + var root = ToolFixture.CreateRepository(ToolFixture.IntegrityBreach); + + try + { + // Act + var (exitCode, _, error) = ToolFixture.Run("validate", $"--repository={root}"); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Findings); + await Assert.That(error).Contains("is not internally consistent"); + await Assert.That(error).Contains("mayDependOn names 'Missing'"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Run_GivenValidateAndAMissingRepository_ReturnsFindings() + { + // Arrange + var missing = Path.Combine(Path.GetTempPath(), "PurviewArchitestsCli", Guid.NewGuid().ToString("N")); + + // Act + var (exitCode, _, error) = ToolFixture.Run("validate", "--repository", missing); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Findings); + await Assert.That(error).Contains("does not exist"); + } + + [Test] + public async Task Run_GivenValidateAndAnUnknownOption_ReturnsUsageError() + { + // Act + var (exitCode, _, error) = ToolFixture.Run("validate", "--frobnicate"); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.UsageError); + await Assert.That(error).Contains("Unknown option '--frobnicate'"); + } + + [Test] + public async Task Run_GivenValidateAndHelp_WritesTheCommandUsage() + { + // Act + var (exitCode, output, _) = ToolFixture.Run("validate", "--help"); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("architests validate"); + } +} diff --git a/src/tests/ArchitectureTesting.CLI.UnitTests/GraphCommandTests.cs b/src/tests/ArchitectureTesting.CLI.UnitTests/GraphCommandTests.cs new file mode 100644 index 0000000..b7824d6 --- /dev/null +++ b/src/tests/ArchitectureTesting.CLI.UnitTests/GraphCommandTests.cs @@ -0,0 +1,117 @@ +using Purview.Architecture.CLI; + +namespace Purview.Architecture.Testing.CLI.UnitTests; + +/// Exercises architests graph. +public sealed class GraphCommandTests +{ + [Test] + public async Task Run_GivenTextFormat_RendersProjectsAndEdges() + { + // Arrange + var root = CreateRepository(); + + try + { + // Act + var (exitCode, output, _) = ToolFixture.Run("graph", "--repository", root); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("Application (Application, application)"); + await Assert.That(output).Contains(" -> Core"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Run_GivenMermaidFormat_RendersAFlowchart() + { + // Arrange + var root = CreateRepository(); + + try + { + // Act + var (exitCode, output, _) = ToolFixture.Run("graph", "--format", "mermaid", "--repository", root); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("graph LR"); + await Assert.That(output).Contains("Application --> Core"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Run_GivenAnUnknownFormat_ReturnsUsageError() + { + // Act + var (exitCode, _, error) = ToolFixture.Run("graph", "--format", "dot"); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.UsageError); + await Assert.That(error).Contains("Unknown format 'dot'"); + } + + /// Writes a two-project repository with a policy. + /// The repository root. + static string CreateRepository() + { + var root = Directory.CreateTempSubdirectory("PurviewGraphCli").FullName; + var config = Path.Combine(root, ".config"); + Directory.CreateDirectory(config); + + File.WriteAllText( + Path.Combine(config, "architecture-policy.json"), + /*lang=json,strict*/ + """ + { + "version": 3, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "category": "domain", + "purpose": "The domain model used by the graph fixture.", + "mayDependOn": [], + "mustNotDependOn": [] + }, + { + "name": "Application", + "category": "application", + "purpose": "The use-case layer used by the graph fixture.", + "mayDependOn": ["Core"], + "mustNotDependOn": [] + } + ] + } + """ + ); + + var core = Path.Combine(root, "src", "Core"); + Directory.CreateDirectory(core); + File.WriteAllText(Path.Combine(core, "Core.csproj"), """"""); + + var application = Path.Combine(root, "src", "Application"); + Directory.CreateDirectory(application); + File.WriteAllText( + Path.Combine(application, "Application.csproj"), + """ + + + + + + """ + ); + + return root; + } +} diff --git a/src/tests/ArchitectureTesting.CLI.UnitTests/ToolFixture.cs b/src/tests/ArchitectureTesting.CLI.UnitTests/ToolFixture.cs new file mode 100644 index 0000000..3c1c98a --- /dev/null +++ b/src/tests/ArchitectureTesting.CLI.UnitTests/ToolFixture.cs @@ -0,0 +1,101 @@ +using Purview.Architecture.CLI; + +namespace Purview.Architecture.Testing.CLI.UnitTests; + +/// Builds a throwaway repository and drives the tool against it. +/// +/// The commands take a real policy file on disk, so the tests use the temp directory rather than an +/// abstraction. Every path is composed with , so the suite runs on +/// Windows, Linux and macOS alike. +/// +static class ToolFixture +{ + /// A policy that passes the schema and the integrity rule. + public const string ValidPolicy = /*lang=json,strict*/ + """ + { + "version": 3, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "category": "domain", + "purpose": "The fundamental domain model and its rules.", + "mayDependOn": [], + "mustNotDependOn": [] + } + ] + } + """; + + /// A policy that omits the required responsibility catalogue. + public const string SchemaBreach = /*lang=json,strict*/ + """{ "version": 3 }"""; + + /// A policy whose matrix names a responsibility that does not exist. + public const string IntegrityBreach = /*lang=json,strict*/ + """ + { + "version": 3, + "responsibilities": [ + { + "name": "Core", + "category": "domain", + "purpose": "The fundamental domain model and its rules.", + "mayDependOn": ["Missing"], + "mustNotDependOn": [] + } + ] + } + """; + + /// A policy with one waiver whose review date is long past the maximum age. + public const string StaleWaiver = /*lang=json,strict*/ + """ + { + "version": 3, + "responsibilities": [ + { + "name": "Core", + "category": "domain", + "purpose": "The fundamental domain model and its rules.", + "mayDependOn": [], + "mustNotDependOn": [] + } + ], + "waiverPolicy": { "maxAgeDays": 30 }, + "waivers": [ + { + "project": "Legacy.Utilities", + "rules": ["naming"], + "reason": "Predates the catalogue; renaming breaks a published package identifier.", + "reviewedOn": "2020-01-01" + } + ] + } + """; + + /// Creates a repository holding one policy file. + /// The policy document. + /// The repository root. + public static string CreateRepository(string policy) + { + var root = Directory.CreateTempSubdirectory("PurviewArchitestsCli").FullName; + Directory.CreateDirectory(Path.Combine(root, ".config")); + File.WriteAllText(Path.Combine(root, ArchitecturePolicy.RelativePath), policy); + + return root; + } + + /// Runs the tool with captured output. + /// The command-line arguments. + /// The exit code, standard output and standard error. + public static (int ExitCode, string Output, string Error) Run(params string[] args) + { + using StringWriter output = new(); + using StringWriter error = new(); + var exitCode = CommandLine.Run(args, output, error); + + return (exitCode, output.ToString(), error.ToString()); + } +} diff --git a/src/tests/ArchitectureTesting.CLI.UnitTests/WaiversCommandTests.cs b/src/tests/ArchitectureTesting.CLI.UnitTests/WaiversCommandTests.cs new file mode 100644 index 0000000..0405e5e --- /dev/null +++ b/src/tests/ArchitectureTesting.CLI.UnitTests/WaiversCommandTests.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using Purview.Architecture.CLI; + +namespace Purview.Architecture.Testing.CLI.UnitTests; + +/// Exercises architests waivers list. +public sealed class WaiversCommandTests +{ + [Test] + public async Task Run_GivenWaiversListAndNoWaivers_Succeeds() + { + // Arrange + var root = ToolFixture.CreateRepository(ToolFixture.ValidPolicy); + + try + { + // Act + var (exitCode, output, _) = ToolFixture.Run("waivers", "list", "--repository", root); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("No waivers."); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Run_GivenAStaleWaiverWithoutTheStaleFlag_ListsItAndSucceeds() + { + // Arrange + // The listing is informational; only --stale turns a stale waiver into a failing run. + var root = ToolFixture.CreateRepository(ToolFixture.StaleWaiver); + + try + { + // Act + var (exitCode, output, _) = ToolFixture.Run("waivers", "list", "--repository", root); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("Legacy.Utilities [naming]"); + await Assert.That(output).Contains("beyond waiverPolicy.maxAgeDays of 30"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Run_GivenAStaleWaiverAndTheStaleFlag_ReturnsFindings() + { + // Arrange + var root = ToolFixture.CreateRepository(ToolFixture.StaleWaiver); + + try + { + // Act + var (exitCode, output, _) = ToolFixture.Run("waivers", "list", "--stale", "--repository", root); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Findings); + await Assert.That(output).Contains("Legacy.Utilities"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Run_GivenWaiversWithoutTheListVerb_ReturnsUsageError() + { + // Act + var (exitCode, _, error) = ToolFixture.Run("waivers"); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.UsageError); + await Assert.That(error).Contains("Expected 'list'"); + } + + [Test] + public async Task Run_GivenWaiversListWithOnlyACurrentWaiver_SaysThereAreNoStaleOnes() + { + // Arrange + // The same waiver, reviewed today, is inside the 30-day maximum. + var reviewedOn = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var policy = ToolFixture.StaleWaiver.Replace("2020-01-01", reviewedOn, StringComparison.Ordinal); + var root = ToolFixture.CreateRepository(policy); + + try + { + // Act + var (exitCode, output, _) = ToolFixture.Run("waivers", "list", "--stale", "--repository", root); + + // Assert + await Assert.That(exitCode).IsEqualTo(CommandLine.Success); + await Assert.That(output).Contains("No stale waivers."); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/APIBaselineComparerTests.cs b/src/tests/ArchitectureTesting.UnitTests/APIBaselineComparerTests.cs new file mode 100644 index 0000000..5e84ad9 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/APIBaselineComparerTests.cs @@ -0,0 +1,119 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the baseline comparison. +public sealed class APIBaselineComparerTests +{ + static ArchitecturePolicy Policy => + new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "domain", + Purpose = "A responsibility declared by the baseline fixture.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + }; + + static APIBaseline Capture(params APIType[] types) => + APIBaseline.Capture( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "Core", [], types), + }, + Policy + ); + + [Test] + public async Task Compare_GivenIdenticalBaselines_ReportsNothing() + { + // Arrange + var baseline = Capture(TypeRuleFixture.Type("Order")); + + // Act + var changes = APIBaselineComparer.Compare(baseline, Capture(TypeRuleFixture.Type("Order"))); + + // Assert + await Assert.That(changes).IsEmpty(); + } + + [Test] + public async Task Compare_GivenAnAddedType_ReportsAnAddition() + { + // Arrange + var baseline = Capture(TypeRuleFixture.Type("Order")); + + // Act + var changes = APIBaselineComparer.Compare( + baseline, + Capture(TypeRuleFixture.Type("Order"), TypeRuleFixture.Type("Customer")) + ); + + // Assert + await Assert.That(changes).HasSingleItem(); + await Assert.That(changes[0].Kind).IsEqualTo(APIBaselineChangeKind.Added); + await Assert.That(changes[0].Subject).IsEqualTo("Purview.Core.Customer"); + } + + [Test] + public async Task Compare_GivenARemovedMember_ReportsItAsBreaking() + { + // Arrange + var before = Capture(TypeRuleFixture.Type("Order", members: [TypeRuleFixture.Member("Cancel")])); + + // Act + var changes = APIBaselineComparer.Compare(before, Capture(TypeRuleFixture.Type("Order"))); + + // Assert + await Assert.That(changes).HasSingleItem(); + await Assert.That(changes[0].Kind).IsEqualTo(APIBaselineChangeKind.Removed); + await Assert.That(changes[0].Detail).Contains("breaking change"); + } + + [Test] + public async Task Compare_GivenAChangedMemberType_ReportsTheChange() + { + // Arrange + var before = Capture( + TypeRuleFixture.Type( + "Order", + members: [TypeRuleFixture.Member("Status", APIMemberKind.Property, "System.String")] + ) + ); + + var after = Capture( + TypeRuleFixture.Type( + "Order", + members: [TypeRuleFixture.Member("Status", APIMemberKind.Property, "System.Int32")] + ) + ); + + // Act + var changes = APIBaselineComparer.Compare(before, after); + + // Assert + await Assert.That(changes).HasSingleItem(); + await Assert.That(changes[0].Kind).IsEqualTo(APIBaselineChangeKind.Changed); + await Assert.That(changes[0].Detail).Contains("System.String"); + await Assert.That(changes[0].Detail).Contains("System.Int32"); + } + + [Test] + public async Task Compare_GivenAChangedTypeShape_ReportsTheShapeChange() + { + // Arrange + var before = Capture(TypeRuleFixture.Type("Order", isSealed: true)); + + // Act + var changes = APIBaselineComparer.Compare(before, Capture(TypeRuleFixture.Type("Order", isSealed: false))); + + // Assert + await Assert.That(changes).HasSingleItem(); + await Assert.That(changes[0].Detail).Contains("sealed changed from True to False"); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/APIBaselineTests.cs b/src/tests/ArchitectureTesting.UnitTests/APIBaselineTests.cs new file mode 100644 index 0000000..73e210e --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/APIBaselineTests.cs @@ -0,0 +1,127 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises baseline capture and canonical JSON. +public sealed class APIBaselineTests +{ + static ArchitecturePolicy Policy => + new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "domain", + Purpose = "A responsibility declared by the baseline fixture.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + }; + + static APIAssembly Assembly(params APIType[] types) => new("Core", "Core", [], types); + + [Test] + public async Task Capture_GivenInternalAndPublicTypes_RecordsOnlyThePublicSurface() + { + // Arrange + Dictionary surfaces = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = Assembly( + TypeRuleFixture.Type("Order"), + TypeRuleFixture.Type("OrderRepository", isPublic: false) + ), + }; + + // Act + var baseline = APIBaseline.Capture(surfaces, Policy); + + // Assert + await Assert.That(baseline.Projects).HasSingleItem(); + await Assert.That(baseline.Projects[0].Types).HasSingleItem(); + await Assert.That(baseline.Projects[0].Types[0].Name).IsEqualTo("Purview.Core.Order"); + await Assert.That(baseline.Projects[0].Responsibility).IsEqualTo("Core"); + } + + [Test] + public async Task ToCanonicalJson_GivenReorderedInput_IsStable() + { + // Arrange + var first = APIBaseline.Capture( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = Assembly(TypeRuleFixture.Type("Order"), TypeRuleFixture.Type("Customer")), + }, + Policy + ); + + var second = APIBaseline.Capture( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = Assembly(TypeRuleFixture.Type("Customer"), TypeRuleFixture.Type("Order")), + }, + Policy + ); + + // Act + var canonical = first.ToCanonicalJson(); + + // Assert + await Assert.That(canonical).IsEqualTo(second.ToCanonicalJson()); + await Assert.That(canonical.EndsWith('\n')).IsTrue(); + await Assert.That(canonical).DoesNotContain("\r"); + } + + [Test] + public async Task WriteAndRead_GivenABaseline_RoundTripsTheSameDocument() + { + // Arrange + var baseline = APIBaseline.Capture( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = Assembly( + TypeRuleFixture.Type("Order", members: [TypeRuleFixture.Member("Cancel", typeName: "System.Void")]) + ), + }, + Policy + ); + + var path = Path.Combine(Path.GetTempPath(), $"PurviewBaseline{Guid.NewGuid():N}.json"); + + try + { + // Act + baseline.Write(path); + var read = APIBaseline.Read(path); + + // Assert + await Assert.That(read.ToCanonicalJson()).IsEqualTo(baseline.ToCanonicalJson()); + await Assert.That(read.Projects[0].Types[0].Members).HasSingleItem(); + } + finally + { + File.Delete(path); + } + } + + [Test] + public async Task Capture_GivenAMethod_RecordsItsParameterTypes() + { + // Arrange + var method = TypeRuleFixture.Member("Cancel", typeName: "System.Boolean"); + method = method with { Parameters = ["System.String", "System.Int32"] }; + + Dictionary surfaces = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = Assembly(TypeRuleFixture.Type("Order", members: [method])), + }; + + // Act + var baseline = APIBaseline.Capture(surfaces, Policy); + + // Assert + var member = baseline.Projects[0].Types[0].Members.Single(); + await Assert.That(member.Parameters).IsEquivalentTo(["System.String", "System.Int32"]); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/APISurfaceTests.cs b/src/tests/ArchitectureTesting.UnitTests/APISurfaceTests.cs new file mode 100644 index 0000000..d8b84e1 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/APISurfaceTests.cs @@ -0,0 +1,157 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// +/// Exercises the API-surface reader against a real compiled assembly. +/// +/// +/// The rules are tested against in-memory fixtures; the reader is tested against this test assembly +/// and the package it references, so a metadata bug cannot hide behind a hand-built model. +/// +public sealed class APISurfaceTests +{ + static readonly string PackageAssembly = typeof(APISurface).Assembly.Location; + + static IReadOnlyList PackageTypes => APISurface.Read(PackageAssembly); + + static APIType PackageType(string name) => + PackageTypes.Single(type => type.Name == name && type.Namespace == "Purview.Architecture.Testing"); + + [Test] + public async Task Read_GivenThePackageAssembly_DescribesAPublicSealedClass() + { + // Act + var type = PackageType("ArchitecturePolicyEvaluator"); + + // Assert + await Assert.That(type.Kind).IsEqualTo(APITypeKind.Class); + await Assert.That(type.IsPublic).IsTrue(); + await Assert.That(type.IsSealed).IsTrue(); + await Assert.That(type.IsAbstract).IsFalse(); + await Assert.That(type.Namespace).IsEqualTo("Purview.Architecture.Testing"); + } + + [Test] + public async Task Read_GivenAProperty_ReportsItsDeclaredType() + { + // Arrange + var policy = PackageType("ArchitecturePolicy"); + + // Act + var waivers = policy.Members.Single(member => member.Name == "Waivers"); + + // Assert + await Assert.That(waivers.Kind).IsEqualTo(APIMemberKind.Property); + await Assert.That(waivers.TypeName).Contains("System.Collections.Generic.IReadOnlyList<"); + await Assert.That(waivers.IsStatic).IsFalse(); + } + + [Test] + public async Task Read_GivenAnInterface_ReportsTheInterfaceKindAndItsIPrefix() + { + // Act + var type = PackageType("IArchitectureRule"); + + // Assert + await Assert.That(type.Kind).IsEqualTo(APITypeKind.Interface); + await Assert.That(type.Name.StartsWith('I')).IsTrue(); + await Assert.That(type.IsPublic).IsTrue(); + } + + [Test] + public async Task Read_GivenAStaticClass_ReportsItAsAbstractAndSealed() + { + // Act + var type = PackageType("ProjectGraph"); + + // Assert + await Assert.That(type.IsStatic).IsTrue(); + await Assert.That(type.IsAbstract).IsTrue(); + await Assert.That(type.IsSealed).IsTrue(); + } + + [Test] + public async Task Read_GivenAnAttributeWithStringArguments_DecodesTheFirstOne() + { + // Arrange + // ArchitecturePolicy.Version carries [JsonPropertyName("version")]. + var type = PackageType("ArchitecturePolicy"); + var property = type.Members.Single(member => member.Name == "Version"); + + // Act + var attribute = property.Attributes.Single(candidate => + candidate.TypeName == "System.Text.Json.Serialization.JsonPropertyNameAttribute" + ); + + // Assert + await Assert.That(attribute.StringArgument).IsEqualTo("version"); + } + + [Test] + public async Task ReadAssembly_GivenAProjectAssembly_AttributesItThroughTheSdkMarker() + { + // Arrange + // The package's assembly name is not its project name, so attribution must use the marker. + // Test projects suppress the marker, which is why this uses a production-shaped assembly. + var projectName = "ArchitectureTesting"; + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + [projectName] = new(projectName, "/repo/src/ArchitectureTesting/x.csproj", [], [], false), + }; + + // Act + var surface = APISurface.ReadAssembly(PackageAssembly, projects); + + // Assert + await Assert.That(surface).IsNotNull(); + await Assert.That(surface!.ProjectName).IsEqualTo(projectName); + await Assert.That(surface.Types).IsNotEmpty(); + } + + [Test] + public async Task ReadAssembly_GivenANonProjectAssembly_ReturnsNothing() + { + // Arrange + // The package assembly maps to no project in this dictionary, so it is a package. + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + ["Unrelated"] = new("Unrelated", "/repo/src/Unrelated/Unrelated.csproj", [], [], false), + }; + + // Act + var surface = APISurface.ReadAssembly(PackageAssembly, projects); + + // Assert + await Assert.That(surface).IsNull(); + } + + [Test] + public async Task Read_GivenAFileThatIsNotAnAssembly_ReturnsNothing() + { + // Arrange + var path = Path.Combine(Path.GetTempPath(), $"PurviewAPISurface{Guid.NewGuid():N}.dll"); + await File.WriteAllTextAsync(path, "this is not a portable executable"); + + try + { + // Act + var types = APISurface.Read(path); + + // Assert + await Assert.That(types).IsEmpty(); + } + finally + { + File.Delete(path); + } + } + + [Test] + public async Task Read_GivenThePackageAssembly_DoesNotReturnTheSyntheticModuleType() + { + // Act + var modules = PackageTypes.Where(type => type.Name == "").ToArray(); + + // Assert + await Assert.That(modules).IsEmpty(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyEvaluatorTests.cs b/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyEvaluatorTests.cs index 0f7460f..ade17f7 100644 --- a/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyEvaluatorTests.cs +++ b/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyEvaluatorTests.cs @@ -394,7 +394,7 @@ public async Task EvaluateAll_GivenACleanSolution_ReportsNothing() { // Arrange var evaluator = Evaluator( - Policy(Core, Application, Persistence, Composition), + Policy(Core, Application, Persistence, Composition, Shared), Project("Core"), Project("Application", ["Core"]), Project("Persistence", ["Core", "Application"]), @@ -415,7 +415,7 @@ public async Task TestProjects_GivenTheirReferences_AreNotGovernedByTheMatrix() // A test project references everything by design; applying the matrix to it would make the // rule set unusable. var evaluator = Evaluator( - Policy(Core, Persistence), + Policy(Core, Application, Persistence, Composition, Shared), Project("Core.UnitTests", ["Core", "Persistence"], isTest: true), Project("Core"), Project("Persistence") diff --git a/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyReaderTests.cs b/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyReaderTests.cs new file mode 100644 index 0000000..d26e799 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyReaderTests.cs @@ -0,0 +1,93 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Reads the package-scope switch from a policy document. +public sealed class ArchitecturePolicyReaderTests +{ + [Test] + public async Task Load_GivenAForbiddenPackageScope_ReadsIt() + { + // Arrange + var root = CreateRepository( + /*lang=json,strict*/ + """ + { + "version": 3, + "responsibilities": [ + { + "name": "Api", + "category": "host", + "purpose": "The host that composes the application.", + "mayDependOn": [], + "mustNotDependOn": [], + "forbiddenPackageScope": "closure" + } + ] + } + """ + ); + + try + { + // Act + var policy = ArchitecturePolicy.Load(root); + + // Assert + await Assert.That(policy.Responsibilities[0].ForbiddenPackageScope).IsEqualTo("closure"); + + var failures = PolicySchemaValidator.Validate(Path.Combine(root, ArchitecturePolicy.RelativePath)); + await Assert.That(failures).IsEmpty(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Load_GivenNoForbiddenPackageScope_DefaultsToOwn() + { + // Arrange + var root = CreateRepository( + /*lang=json,strict*/ + """ + { + "version": 3, + "responsibilities": [ + { + "name": "Api", + "category": "host", + "purpose": "The host that composes the application.", + "mayDependOn": [], + "mustNotDependOn": [] + } + ] + } + """ + ); + + try + { + // Act + var policy = ArchitecturePolicy.Load(root); + + // Assert + await Assert.That(policy.Responsibilities[0].ForbiddenPackageScope).IsEqualTo("own"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// Writes a policy under a temporary repository root. + /// The policy document. + /// The repository root. + static string CreateRepository(string policy) + { + var root = Directory.CreateTempSubdirectory("PurviewArchitecturePolicy").FullName; + var config = Path.Combine(root, ".config"); + Directory.CreateDirectory(config); + File.WriteAllText(Path.Combine(config, "architecture-policy.json"), policy); + return root; + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/AssemblyRuleTests.cs b/src/tests/ArchitectureTesting.UnitTests/AssemblyRuleTests.cs index 88755a6..75fdc8f 100644 --- a/src/tests/ArchitectureTesting.UnitTests/AssemblyRuleTests.cs +++ b/src/tests/ArchitectureTesting.UnitTests/AssemblyRuleTests.cs @@ -376,7 +376,12 @@ await Assert public async Task EvaluateAll_GivenAnAdditionalRule_ReportsItsViolation() { // Arrange - var evaluator = Evaluator(Policy(Core), [Project("Core")], [], [new AlwaysViolatesRule("noConsoleLogging")]); + var evaluator = Evaluator( + Policy(Core, Shared), + [Project("Core")], + [], + [new AlwaysViolatesRule("noConsoleLogging")] + ); // Act var violations = evaluator.EvaluateAll(); diff --git a/src/tests/ArchitectureTesting.UnitTests/AttributePolicyFriendTests.cs b/src/tests/ArchitectureTesting.UnitTests/AttributePolicyFriendTests.cs new file mode 100644 index 0000000..f76897d --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/AttributePolicyFriendTests.cs @@ -0,0 +1,134 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// +/// Exercises the friend-assembly check and the type-rule dispatch. +/// +public sealed class AttributePolicyFriendTests +{ + [Test] + public async Task AttributePolicy_GivenInternalsVisibleToProduction_ReportsIt() + { + // Arrange + var evaluator = EvaluatorWithFriend("Other", isTest: false); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("production assembly 'Other'"); + } + + [Test] + public async Task AttributePolicy_GivenInternalsVisibleToATestProject_ReportsNothing() + { + // Arrange + var evaluator = EvaluatorWithFriend("Other", isTest: true); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task EnabledTypeRules_GivenTypeRulesOn_ListsEveryTypeRule() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator(TypeRuleFixture.Rules); + + // Act + var rules = evaluator.EnabledTypeRules(); + + // Assert + await Assert.That(rules.Count).IsEqualTo(4); + await Assert.That(rules).Contains(ArchitectureRules.TypeNaming); + await Assert.That(rules).Contains(ArchitectureRules.AttributePolicy); + } + + [Test] + public async Task EnabledTypeRules_GivenTypeRulesOff_ListsNothing() + { + // Arrange + // A disabled group must produce no test, not a test that checks nothing. + var evaluator = TypeRuleFixture.Evaluator(new TypeRules { Enabled = false }); + + // Act + var rules = evaluator.EnabledTypeRules(); + + // Assert + await Assert.That(rules).IsEmpty(); + } + + [Test] + public async Task EvaluateTypeRule_GivenAnUnknownRule_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator(TypeRuleFixture.Rules); + + // Act + var violations = evaluator.EvaluateTypeRule("notATypeRule"); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + /// Builds an evaluator whose assembly friends another project. + /// The friend's project and assembly name. + /// Whether the friend is a test project. + /// The evaluator. + static ArchitecturePolicyEvaluator EvaluatorWithFriend(string friend, bool isTest) + { + ArchitecturePolicy policy = new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "shared", + Purpose = "A responsibility declared by the attribute-rule fixture.", + MayDependOn = [], + MustNotDependOn = [], + }, + new Responsibility + { + Name = friend, + Category = "shared", + Purpose = "The friend project declared by the attribute-rule fixture.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + TypeRules = TypeRuleFixture.Rules, + }; + + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "/repo/src/Core/Core.csproj", [], [], false) { RootNamespace = "Purview.Core" }, + [friend] = new(friend, $"/repo/src/{friend}/{friend}.csproj", [], [], isTest) + { + RootNamespace = $"Purview.{friend}", + }, + }; + + Dictionary surfaces = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new( + "Core", + "Core", + [new APIAttribute("System.Runtime.CompilerServices.InternalsVisibleToAttribute", friend)], + [] + ), + }; + + return new ArchitecturePolicyEvaluator( + policy, + projects, + new Dictionary(), + apiSurfaces: surfaces + ); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/AttributePolicyRuleTests.cs b/src/tests/ArchitectureTesting.UnitTests/AttributePolicyRuleTests.cs new file mode 100644 index 0000000..8cab693 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/AttributePolicyRuleTests.cs @@ -0,0 +1,136 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the attribute rule against in-memory surfaces. +public sealed class AttributePolicyRuleTests +{ + [Test] + public async Task AttributePolicy_GivenAMissingRequiredAttribute_ReportsIt() + { + // Arrange + TypeRules rules = new() { Enabled = true, RequiredAttributes = ["Contoso.AuditedAttribute"] }; + + var evaluator = TypeRuleFixture.Evaluator(rules, TypeRuleFixture.Type("Order")); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.AttributePolicy); + await Assert.That(violations[0].Message).Contains("missing the required attribute"); + } + + [Test] + public async Task AttributePolicy_GivenAPresentRequiredAttribute_ReportsNothing() + { + // Arrange + TypeRules rules = new() { Enabled = true, RequiredAttributes = ["Contoso.AuditedAttribute"] }; + + var evaluator = TypeRuleFixture.Evaluator( + rules, + TypeRuleFixture.Type("Order", attributes: [new APIAttribute("Contoso.AuditedAttribute", null)]) + ); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task AttributePolicy_GivenAForbiddenAttribute_ReportsIt() + { + // Arrange + TypeRules rules = new() { Enabled = true, ForbiddenAttributes = ["System.SerializableAttribute"] }; + + var evaluator = TypeRuleFixture.Evaluator( + rules, + TypeRuleFixture.Type("Order", attributes: [new APIAttribute("System.SerializableAttribute", null)]) + ); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("forbidden attribute"); + } + + [Test] + public async Task AttributePolicy_GivenObsoleteWithoutAMessage_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("Order", attributes: [new APIAttribute("System.ObsoleteAttribute", null)]) + ); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("[Obsolete] without a message"); + } + + [Test] + public async Task AttributePolicy_GivenObsoleteWithAMessage_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type( + "Order", + attributes: [new APIAttribute("System.ObsoleteAttribute", "Use PurchaseOrder instead.")] + ) + ); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task AttributePolicy_GivenObsoleteAllowedWithoutAMessage_ReportsNothing() + { + // Arrange + TypeRules rules = new() { Enabled = true, AllowObsoleteWithoutMessage = true }; + + var evaluator = TypeRuleFixture.Evaluator( + rules, + TypeRuleFixture.Type("Order", attributes: [new APIAttribute("System.ObsoleteAttribute", null)]) + ); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task AttributePolicy_GivenAnObsoleteMemberWithoutAMessage_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type( + "Order", + members: + [ + TypeRuleFixture.Member("Cancel", attributes: [new APIAttribute("System.ObsoleteAttribute", null)]), + ] + ) + ); + + // Act + var violations = evaluator.AttributePolicy(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("member 'Purview.Core.Order.Cancel'"); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/ContractRuleTests.cs b/src/tests/ArchitectureTesting.UnitTests/ContractRuleTests.cs new file mode 100644 index 0000000..3c8fc02 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/ContractRuleTests.cs @@ -0,0 +1,207 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the API surface and documentation rules. +public sealed class ContractRuleTests +{ + static APIRules Enabled => new() { Enabled = true }; + + static ArchitecturePolicy Policy(APIRules api) => + new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "domain", + Purpose = "A responsibility declared by the contract-rule fixture.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + APIRules = api, + }; + + static ArchitecturePolicyEvaluator Evaluator( + APIRules api, + APIBaseline? baseline, + APIType[] types, + string assemblyPath = "" + ) + { + var policy = Policy(api); + + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "/repo/src/Core/Core.csproj", [], [], false) { RootNamespace = "Purview.Core" }, + }; + + Dictionary surfaces = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "Core", [], types) { Path = assemblyPath }, + }; + + return new ArchitecturePolicyEvaluator( + policy, + projects, + new Dictionary(), + apiSurfaces: surfaces, + apiBaseline: baseline + ); + } + + static APIBaseline Capture(params APIType[] types) => + APIBaseline.Capture( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "Core", [], types), + }, + Policy(Enabled) + ); + + [Test] + public async Task APISurfaceCompatibility_GivenNoBaseline_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Enabled, baseline: null, [TypeRuleFixture.Type("Order")]); + + // Act + var violations = evaluator.APISurfaceCompatibility(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.APISurface); + await Assert.That(violations[0].Message).Contains("No API baseline was found"); + } + + [Test] + public async Task APISurfaceCompatibility_GivenAMatchingBaseline_ReportsNothing() + { + // Arrange + APIType[] types = [TypeRuleFixture.Type("Order")]; + var evaluator = Evaluator(Enabled, Capture(types), types); + + // Act + var violations = evaluator.APISurfaceCompatibility(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task APISurfaceCompatibility_GivenAnAddedType_ReportsIt() + { + // Arrange + var evaluator = Evaluator( + Enabled, + Capture(TypeRuleFixture.Type("Order")), + [TypeRuleFixture.Type("Order"), TypeRuleFixture.Type("Customer")] + ); + + // Act + var violations = evaluator.APISurfaceCompatibility(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("Purview.Core.Customer"); + await Assert.That(violations[0].Message).Contains("api capture"); + } + + [Test] + public async Task APIDocumentation_GivenNoDocumentationFile_ReportsTheProject() + { + // Arrange + var evaluator = Evaluator(Enabled, baseline: null, [TypeRuleFixture.Type("Order")]); + + // Act + var violations = evaluator.APIDocumentation(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.APIDocumentation); + await Assert.That(violations[0].Message).Contains("XML documentation"); + } + + [Test] + public async Task APIDocumentation_GivenAnUndocumentedType_ReportsIt() + { + // Arrange + var directory = Directory.CreateTempSubdirectory("PurviewDocs").FullName; + var assembly = Path.Combine(directory, "Core.dll"); + await File.WriteAllTextAsync( + Path.ChangeExtension(assembly, ".xml"), + """""" + ); + + try + { + var evaluator = Evaluator(Enabled, baseline: null, [TypeRuleFixture.Type("Order")], assembly); + + // Act + var violations = evaluator.APIDocumentation(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("Purview.Core.Order"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task APIDocumentation_GivenADocumentedType_ReportsNothing() + { + // Arrange + var directory = Directory.CreateTempSubdirectory("PurviewDocs").FullName; + var assembly = Path.Combine(directory, "Core.dll"); + await File.WriteAllTextAsync( + Path.ChangeExtension(assembly, ".xml"), + """""" + ); + + try + { + var evaluator = Evaluator(Enabled, baseline: null, [TypeRuleFixture.Type("Order")], assembly); + + // Act + var violations = evaluator.APIDocumentation(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task EnabledAPIRules_GivenTheGroupOn_ListsBothRules() + { + // Arrange + var evaluator = Evaluator(Enabled, baseline: null, []); + + // Act + var rules = evaluator.EnabledAPIRules(); + + // Assert + await Assert.That(rules).Contains(ArchitectureRules.APISurface); + await Assert.That(rules).Contains(ArchitectureRules.APIDocumentation); + } + + [Test] + public async Task EnabledAPIRules_GivenTheGroupOff_ListsNothing() + { + // Arrange + var evaluator = Evaluator(new APIRules { Enabled = false }, baseline: null, []); + + // Act + var rules = evaluator.EnabledAPIRules(); + + // Assert + await Assert.That(rules).IsEmpty(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/DependencyBudgetTests.cs b/src/tests/ArchitectureTesting.UnitTests/DependencyBudgetTests.cs new file mode 100644 index 0000000..b642761 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/DependencyBudgetTests.cs @@ -0,0 +1,193 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the dependency-budget ratchet. +public sealed class DependencyBudgetTests +{ + static DependencyBudget Budget(params DependencyBudgetEntry[] entries) => + new() + { + Enabled = true, + FailOnStale = true, + Entries = entries, + }; + + static DependencyBudgetEntry Entry(string project, params string[] references) => + new() { Project = project, References = references }; + + static ArchitecturePolicy Policy(DependencyBudget budget) => + new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "shared", + Purpose = "A responsibility declared by the budget fixture.", + MayDependOn = ["Infra"], + MustNotDependOn = [], + }, + new Responsibility + { + Name = "Infra", + Category = "shared", + Purpose = "The project a budgeted reference points at.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + DependencyBudget = budget, + }; + + static ArchitecturePolicyEvaluator Evaluator(DependencyBudget budget, string[]? coreReferences = null) + { + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "/repo/src/Core/Core.csproj", coreReferences ?? [], [], false), + ["Infra"] = new("Infra", "/repo/src/Infra/Infra.csproj", [], [], false), + }; + + return new ArchitecturePolicyEvaluator(Policy(budget), projects, new Dictionary()); + } + + [Test] + public async Task DependencyBudget_GivenAReferenceOutsideTheBudget_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Budget(Entry("Core")), ["Infra"]); + + // Act + var violations = evaluator.DependencyBudgetRule(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.DependencyBudgetRule); + await Assert.That(violations[0].Message).Contains("not in its dependency budget"); + } + + [Test] + public async Task DependencyBudget_GivenAReferenceInsideTheBudget_ReportsNothing() + { + // Arrange + var evaluator = Evaluator(Budget(Entry("Core", "Infra")), ["Infra"]); + + // Act + var violations = evaluator.DependencyBudgetRule(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task DependencyBudget_GivenAStaleAllowance_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Budget(Entry("Core", "Infra")), []); + + // Act + var violations = evaluator.DependencyBudgetRule(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("Tighten the budget"); + } + + [Test] + public async Task DependencyBudget_GivenAStaleAllowanceAndFailOnStaleOff_ReportsNothing() + { + // Arrange + DependencyBudget budget = new() + { + Enabled = true, + FailOnStale = false, + Entries = [Entry("Core", "Infra")], + }; + + var evaluator = Evaluator(budget, []); + + // Act + var violations = evaluator.DependencyBudgetRule(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task DependencyBudget_GivenAnUnknownProject_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Budget(Entry("Missing")), []); + + // Act + var violations = evaluator.DependencyBudgetRule(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("not a production project"); + } + + [Test] + public async Task DependencyBudget_GivenADuplicateEntry_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Budget(Entry("Core"), Entry("Core")), []); + + // Act + var violations = evaluator.DependencyBudgetRule(); + + // Assert + await Assert + .That(violations.Any(violation => violation.Message.Contains("more than once", StringComparison.Ordinal))) + .IsTrue(); + } + + [Test] + public async Task DependencyBudget_GivenTheBudgetOff_ReportsNothing() + { + // Arrange + var evaluator = Evaluator(new DependencyBudget { Enabled = false, Entries = [Entry("Core")] }, ["Infra"]); + + // Act + var violations = evaluator.DependencyBudgetRule(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task DependencyBudget_IsNotWaivable() + { + // Arrange / Act + var waivable = ArchitectureRules.IsWaivable(ArchitectureRules.DependencyBudgetRule); + + // Assert + await Assert.That(waivable).IsFalse(); + } + + [Test] + public async Task EnabledAdoptionRules_GivenTheBudgetOn_ListsTheRule() + { + // Arrange + var evaluator = Evaluator(Budget(), []); + + // Act + var rules = evaluator.EnabledAdoptionRules(); + + // Assert + await Assert.That(rules).Contains(ArchitectureRules.DependencyBudgetRule); + } + + [Test] + public async Task EnabledAdoptionRules_GivenTheBudgetOff_ListsNothing() + { + // Arrange + var evaluator = Evaluator(new DependencyBudget { Enabled = false }, []); + + // Act + var rules = evaluator.EnabledAdoptionRules(); + + // Assert + await Assert.That(rules).IsEmpty(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/ForbiddenPackageOwnershipTests.cs b/src/tests/ArchitectureTesting.UnitTests/ForbiddenPackageOwnershipTests.cs new file mode 100644 index 0000000..cca03f1 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/ForbiddenPackageOwnershipTests.cs @@ -0,0 +1,182 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises package ownership: what a project owns versus what a reference explains. +public sealed class ForbiddenPackageOwnershipTests +{ + internal static ArchitecturePolicy Policy(string scope = "own", string[]? mustNot = null) => + new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Api", + Category = "host", + Purpose = "The host that composes the application.", + MayDependOn = ["Persistence"], + MustNotDependOn = mustNot ?? [], + ForbiddenPackagePrefixes = ["Microsoft.EntityFrameworkCore"], + ForbiddenPackageScope = scope, + }, + new Responsibility + { + Name = "Persistence", + Category = "infrastructure", + Purpose = "The adapter that owns the database technology.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + }; + + internal static PackageTarget Target( + string[] direct, + string[] owned, + string[] resolved, + Dictionary through + ) => + new( + "net10.0", + direct, + owned, + resolved, + through.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value, + StringComparer.OrdinalIgnoreCase + ) + ); + + internal static ArchitecturePolicyEvaluator Evaluator( + ArchitecturePolicy policy, + PackageTarget? target, + IReadOnlyList? packageReferences = null + ) + { + ProjectNode api = new("Api", "/repo/src/Api/Api.csproj", ["Persistence"], packageReferences ?? [], false); + + if (target is not null) + api = api with { PackageTargets = [target], ResolvedPackages = target.Resolved }; + + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + ["Api"] = api, + ["Persistence"] = new("Persistence", "/repo/src/Persistence/Persistence.csproj", [], [], false), + }; + + return new ArchitecturePolicyEvaluator(policy, projects, new Dictionary()); + } + + [Test] + public async Task ForbiddenPackages_GivenAPermittedProjectReference_DoesNotReportItsPackages() + { + // Arrange + // The host may reference Persistence; EF Core is the adapter's choice, not the host's. + var target = Target( + direct: [], + owned: [], + resolved: ["Microsoft.EntityFrameworkCore", "Microsoft.EntityFrameworkCore.Abstractions"], + through: new() + { + ["Persistence"] = ["Microsoft.EntityFrameworkCore", "Microsoft.EntityFrameworkCore.Abstractions"], + } + ); + + var evaluator = Evaluator(Policy(), target); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task ForbiddenPackages_GivenADirectPackage_ReportsIt() + { + // Arrange + // The host declares EF Core itself as well as referencing the adapter. + var target = Target( + direct: ["Microsoft.EntityFrameworkCore"], + owned: ["Microsoft.EntityFrameworkCore"], + resolved: ["Microsoft.EntityFrameworkCore"], + through: new() { ["Persistence"] = ["Microsoft.EntityFrameworkCore"] } + ); + + var evaluator = Evaluator(Policy(), target); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("own package references"); + } + + [Test] + public async Task ForbiddenPackages_GivenAPackageInjectedByBuildProperties_ReportsIt() + { + // Arrange + // The csproj declares nothing, but Directory.Build.props injected the package: restore lists + // it as a direct dependency, so it is owned. + var target = Target( + direct: ["Microsoft.EntityFrameworkCore"], + owned: ["Microsoft.EntityFrameworkCore"], + resolved: ["Microsoft.EntityFrameworkCore"], + through: new() { ["Persistence"] = [] } + ); + + var evaluator = Evaluator(Policy(), target); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + } + + [Test] + public async Task ForbiddenPackages_GivenATransitiveOfAnOwnPackage_ReportsIt() + { + // Arrange + // The host declares Api.Data; EF Core arrives through that package, not a project reference. + var target = Target( + direct: ["Api.Data"], + owned: ["Api.Data", "Microsoft.EntityFrameworkCore"], + resolved: ["Api.Data", "Microsoft.EntityFrameworkCore"], + through: new() { ["Persistence"] = [] } + ); + + var evaluator = Evaluator(Policy(), target); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("own package references"); + } + + [Test] + public async Task ForbiddenPackages_GivenADisallowedProjectReference_ReportsItsPackages() + { + // Arrange + // The reference is forbidden, so its packages are not explained by a permitted reference. + var target = Target( + direct: [], + owned: [], + resolved: ["Microsoft.EntityFrameworkCore"], + through: new() { ["Persistence"] = ["Microsoft.EntityFrameworkCore"] } + ); + + var evaluator = Evaluator(Policy(mustNot: ["Persistence"]), target); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("No permitted project reference"); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/ForbiddenPackageScopeTests.cs b/src/tests/ArchitectureTesting.UnitTests/ForbiddenPackageScopeTests.cs new file mode 100644 index 0000000..94ca109 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/ForbiddenPackageScopeTests.cs @@ -0,0 +1,122 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the closure opt-in, prefix collapsing and the restore fallback. +public sealed class ForbiddenPackageScopeTests +{ + [Test] + public async Task ForbiddenPackages_GivenClosureScope_ReportsInheritedPackages() + { + // Arrange + // The opt-in closure scope restores the strict compile-graph behaviour. + var target = ForbiddenPackageOwnershipTests.Target( + direct: [], + owned: [], + resolved: ["Microsoft.EntityFrameworkCore"], + through: new() { ["Persistence"] = ["Microsoft.EntityFrameworkCore"] } + ); + + var evaluator = ForbiddenPackageOwnershipTests.Evaluator( + ForbiddenPackageOwnershipTests.Policy(scope: "closure"), + target + ); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("restore closure"); + } + + [Test] + public async Task ForbiddenPackages_GivenRelatedPackages_ReportsOneFindingPerPrefix() + { + // Arrange + var target = ForbiddenPackageOwnershipTests.Target( + direct: ["Microsoft.EntityFrameworkCore"], + owned: + [ + "Microsoft.EntityFrameworkCore", + "Microsoft.EntityFrameworkCore.Abstractions", + "Microsoft.EntityFrameworkCore.Analyzers", + ], + resolved: + [ + "Microsoft.EntityFrameworkCore", + "Microsoft.EntityFrameworkCore.Abstractions", + "Microsoft.EntityFrameworkCore.Analyzers", + ], + through: new() { ["Persistence"] = [] } + ); + + var evaluator = ForbiddenPackageOwnershipTests.Evaluator(ForbiddenPackageOwnershipTests.Policy(), target); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("related package"); + } + + [Test] + public async Task ForbiddenPackages_GivenNoPackageGraph_ChecksDeclaredPackages() + { + // Arrange + // Before a restore there is no ownership data, so the declaration is all that can be checked. + var evaluator = ForbiddenPackageOwnershipTests.Evaluator( + ForbiddenPackageOwnershipTests.Policy(), + target: null, + packageReferences: ["Microsoft.EntityFrameworkCore.SqlServer"] + ); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + } + + [Test] + public async Task ForbiddenPackages_GivenAnIncompleteRestore_FallsBackToOwnedPackages() + { + // Arrange + // The permitted reference has no node in the assets file, so its contribution is unknown. + // The rule must not guess: only the packages the project owns outright are checked. + var target = ForbiddenPackageOwnershipTests.Target( + direct: ["Api.Logging"], + owned: ["Api.Logging"], + resolved: ["Api.Logging", "Microsoft.EntityFrameworkCore"], + through: new() + ); + + var evaluator = ForbiddenPackageOwnershipTests.Evaluator(ForbiddenPackageOwnershipTests.Policy(), target); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task OwnedPackages_GivenAPermittedReference_ExcludesItsPackages() + { + // Arrange + var target = ForbiddenPackageOwnershipTests.Target( + direct: ["Api.Logging"], + owned: ["Api.Logging"], + resolved: ["Api.Logging", "Microsoft.EntityFrameworkCore"], + through: new() { ["Persistence"] = ["Microsoft.EntityFrameworkCore"] } + ); + + var evaluator = ForbiddenPackageOwnershipTests.Evaluator(ForbiddenPackageOwnershipTests.Policy(), target); + + // Act + var owned = evaluator.OwnedPackages(evaluator.Projects["Api"]); + + // Assert + await Assert.That(owned).Contains("Api.Logging"); + await Assert.That(owned).DoesNotContain("Microsoft.EntityFrameworkCore"); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/PolicyIntegrityTests.cs b/src/tests/ArchitectureTesting.UnitTests/PolicyIntegrityTests.cs new file mode 100644 index 0000000..deb5bb4 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/PolicyIntegrityTests.cs @@ -0,0 +1,219 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// +/// Exercises the policy's own consistency checks against in-memory policies. +/// +/// +/// These are the failures a schema cannot express: a name that does not exist, a dependency that is +/// both permitted and forbidden, and a prohibition the rest of the policy can bypass. They are +/// policy bugs rather than architectural choices, so the rule is always on and cannot be waived. +/// +public sealed class PolicyIntegrityTests +{ + static Responsibility Responsibility( + string name, + string[]? may = null, + string[]? mustNot = null, + string[]? aliases = null + ) => + new() + { + Name = name, + Aliases = aliases ?? [], + Category = "shared", + Purpose = "A responsibility declared by the policy-integrity fixture.", + MayDependOn = may ?? [], + MustNotDependOn = mustNot ?? [], + }; + + static ArchitecturePolicyEvaluator Evaluator(ArchitecturePolicy policy) => + new(policy, new Dictionary()); + + static ArchitecturePolicy Policy(params Responsibility[] responsibilities) => + new() { Version = 3, Responsibilities = responsibilities }; + + [Test] + public async Task PolicyIntegrity_GivenAnUnknownMayDependOnTarget_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(Responsibility("Core", may: ["Missing"]))); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.PolicyIntegrity); + await Assert.That(violations[0].Project).IsEqualTo("(policy)"); + await Assert.That(violations[0].Message).Contains("mayDependOn names 'Missing'"); + } + + [Test] + public async Task PolicyIntegrity_GivenAnUnknownMustNotDependOnTarget_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(Responsibility("Core", mustNot: ["Missing"]))); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("mustNotDependOn names 'Missing'"); + } + + [Test] + public async Task PolicyIntegrity_GivenADependencyInBothLists_ReportsTheOverlap() + { + // Arrange + // A dependency cannot be both permitted and forbidden. + var evaluator = Evaluator( + Policy( + Responsibility("Core", may: ["Persistence"], mustNot: ["Persistence"]), + Responsibility("Persistence") + ) + ); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("both permits and forbids 'Persistence'"); + } + + [Test] + public async Task PolicyIntegrity_GivenADuplicateResponsibilityName_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(Responsibility("Core"), Responsibility("core"))); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("more than once"); + } + + [Test] + public async Task PolicyIntegrity_GivenAnAliasThatIsAnotherName_ReportsIt() + { + // Arrange + var evaluator = Evaluator( + Policy(Responsibility("Core", aliases: ["Persistence"]), Responsibility("Persistence")) + ); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("which is the responsibility name 'Persistence'"); + } + + [Test] + public async Task PolicyIntegrity_GivenTheSameAliasTwice_ReportsIt() + { + // Arrange + var evaluator = Evaluator( + Policy(Responsibility("Core", aliases: ["Domain"]), Responsibility("Model", aliases: ["Domain"])) + ); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("declared by both 'Core' and 'Model'"); + } + + [Test] + public async Task PolicyIntegrity_GivenAReachableProhibition_ReportsThePath() + { + // Arrange + // Core may use Application, Application may use Persistence, so Core can reach Persistence. + var policy = Policy( + Responsibility("Core", may: ["Application"], mustNot: ["Persistence"]), + Responsibility("Application", may: ["Persistence"]), + Responsibility("Persistence") + ); + + policy = new ArchitecturePolicy + { + Version = policy.Version, + Responsibilities = policy.Responsibilities, + PolicyIntegrity = new PolicyIntegritySettings { CheckReachability = true }, + }; + + var evaluator = Evaluator(policy); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("Core -> Application -> Persistence"); + } + + [Test] + public async Task PolicyIntegrity_GivenAReachableProhibitionWithoutTheOptIn_ReportsNothing() + { + // Arrange + // The direct lists are consistent; only reachability can see the bypass, and it is opt-in. + var evaluator = Evaluator( + Policy( + Responsibility("Core", may: ["Application"], mustNot: ["Persistence"]), + Responsibility("Application", may: ["Persistence"]), + Responsibility("Persistence") + ) + ); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task PolicyIntegrity_GivenAnUnknownForbiddenPackageScope_ReportsIt() + { + // Arrange + var evaluator = Evaluator( + Policy( + new Responsibility + { + Name = "Core", + Category = "shared", + Purpose = "A responsibility declared by the policy-integrity fixture.", + MayDependOn = [], + MustNotDependOn = [], + ForbiddenPackageScope = "everything", + } + ) + ); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("not a scope"); + } + + [Test] + public async Task PolicyIntegrity_GivenAConsistentPolicy_ReportsNothing() + { + // Arrange + var evaluator = Evaluator( + Policy(Responsibility("Core", mustNot: ["Application"]), Responsibility("Application", may: ["Core"])) + ); + + // Act + var violations = evaluator.PolicyIntegrity(); + + // Assert + await Assert.That(violations).IsEmpty(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/PortRuleTests.cs b/src/tests/ArchitectureTesting.UnitTests/PortRuleTests.cs new file mode 100644 index 0000000..62b6327 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/PortRuleTests.cs @@ -0,0 +1,188 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the port implementation and purity rules. +public sealed class PortRuleTests +{ + static PortRules Enabled => new() { Enabled = true }; + + static ArchitecturePolicy Policy(PortRules ports, string coreCategory = "domain") => + new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = coreCategory, + Purpose = "The inner layer whose interfaces are ports.", + MayDependOn = [], + MustNotDependOn = [], + }, + new Responsibility + { + Name = "Infra", + Category = "infrastructure", + Purpose = "The adapter layer that implements the ports.", + MayDependOn = ["Core"], + MustNotDependOn = [], + }, + ], + PortRules = ports, + }; + + static ArchitecturePolicyEvaluator Evaluator( + PortRules ports, + APIType[] coreTypes, + APIType[] infraTypes, + string coreCategory = "domain" + ) + { + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "/repo/src/Core/Core.csproj", [], [], false) { RootNamespace = "Purview.Core" }, + ["Infra"] = new("Infra", "/repo/src/Infra/Infra.csproj", [], [], false) { RootNamespace = "Purview.Infra" }, + }; + + Dictionary surfaces = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "Core", [], coreTypes), + ["Infra"] = new("Infra", "Infra", [], infraTypes), + }; + + return new ArchitecturePolicyEvaluator( + Policy(ports, coreCategory), + projects, + new Dictionary(), + apiSurfaces: surfaces + ); + } + + static APIType Port(params APIMember[] members) => + TypeRuleFixture.Type("IOrderStore", APITypeKind.Interface, members: members); + + public static APIType Implementation => + TypeRuleFixture.Type( + "SqlOrderStore", + project: "Infra", + ns: "Purview.Infra", + interfaces: ["Purview.Core.IOrderStore"] + ); + + [Test] + public async Task PortImplementation_GivenAPortWithNoImplementation_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Enabled, [Port()], []); + + // Act + var violations = evaluator.PortImplementation(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.PortImplementation); + await Assert.That(violations[0].Message).Contains("no public implementation"); + } + + [Test] + public async Task PortImplementation_GivenAnImplementation_ReportsNothing() + { + // Arrange + var evaluator = Evaluator(Enabled, [Port()], [Implementation]); + + // Act + var violations = evaluator.PortImplementation(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task PortImplementation_GivenAPortOutsideThePortCategories_ReportsNothing() + { + // Arrange + // A shared interface is not a port, so an unimplemented one is not a finding. + var evaluator = Evaluator(Enabled, [Port()], [], coreCategory: "shared"); + + // Act + var violations = evaluator.PortImplementation(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task PortPurity_GivenAnInfrastructureTypeInTheSignature_ReportsIt() + { + // Arrange + var evaluator = Evaluator( + Enabled, + [ + Port( + TypeRuleFixture.Member("Context", APIMemberKind.Property, "Microsoft.EntityFrameworkCore.DbContext") + ), + ], + [Implementation] + ); + + // Act + var violations = evaluator.PortPurity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.PortPurity); + await Assert.That(violations[0].Message).Contains("Microsoft.EntityFrameworkCore"); + } + + [Test] + public async Task PortPurity_GivenATechnologyNeutralSignature_ReportsNothing() + { + // Arrange + var evaluator = Evaluator( + Enabled, + [ + Port( + TypeRuleFixture.Member( + "Orders", + APIMemberKind.Property, + "System.Collections.Generic.IReadOnlyList" + ) + ), + ], + [Implementation] + ); + + // Act + var violations = evaluator.PortPurity(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task EnabledPortRules_GivenTheGroupOn_ListsBothRules() + { + // Arrange + var evaluator = Evaluator(Enabled, [], []); + + // Act + var rules = evaluator.EnabledPortRules(); + + // Assert + await Assert.That(rules).Contains(ArchitectureRules.PortImplementation); + await Assert.That(rules).Contains(ArchitectureRules.PortPurity); + } + + [Test] + public async Task EnabledPortRules_GivenTheGroupOff_ListsNothing() + { + // Arrange + var evaluator = Evaluator(new PortRules { Enabled = false }, [], []); + + // Act + var rules = evaluator.EnabledPortRules(); + + // Assert + await Assert.That(rules).IsEmpty(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/ProjectGraphTests.cs b/src/tests/ArchitectureTesting.UnitTests/ProjectGraphTests.cs new file mode 100644 index 0000000..5276901 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/ProjectGraphTests.cs @@ -0,0 +1,252 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the project reader, including the resolved package closure. +public sealed class ProjectGraphTests +{ + [Test] + public async Task Load_GivenARestoreAssetsFile_ReadsTheResolvedClosure() + { + // Arrange + var root = CreateRepository( + /*lang=json,strict*/ + """ + { + "targets": { + "net10.0": { + "Declared.Package/1.0.0": { "type": "package" }, + "Transitive.Package/2.0.0": { "type": "package" }, + "Core/1.0.0": { "type": "project" } + } + } + } + """ + ); + + try + { + // Act + var projects = ProjectGraph.Load(root, ["src"]); + var core = projects["Core"]; + + // Assert + await Assert.That(core.ResolvedPackages).Contains("Declared.Package"); + await Assert.That(core.ResolvedPackages).Contains("Transitive.Package"); + await Assert.That(core.ResolvedPackages).DoesNotContain("Core"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Load_GivenAProjectReference_ReadsThePackagesItContributes() + { + // Arrange + // Direct.Package pulls Transitive.Package. Persistence (a project) pulls EF Core. The + // project reference is not a package, so it explains EF rather than owning it. + var root = CreateRepository( + /*lang=json,strict*/ + """ + { + "targets": { + "net10.0": { + "Direct.Package/1.0.0": { + "type": "package", + "dependencies": { "Transitive.Package": "2.0.0" } + }, + "Transitive.Package/2.0.0": { "type": "package" }, + "Persistence/1.0.0": { + "type": "project", + "dependencies": { "Microsoft.EntityFrameworkCore": "9.0.0" } + }, + "Microsoft.EntityFrameworkCore/9.0.0": { + "type": "package", + "dependencies": { "Microsoft.EntityFrameworkCore.Abstractions": "9.0.0" } + }, + "Microsoft.EntityFrameworkCore.Abstractions/9.0.0": { "type": "package" } + } + }, + "project": { + "frameworks": { + "net10.0": { + "dependencies": { + "Direct.Package": { "target": "Package", "version": "[1.0.0, )" }, + "Persistence": { "target": "Project" } + } + } + } + } + } + """ + ); + + try + { + // Act + var core = ProjectGraph.Load(root, ["src"])["Core"]; + var target = core.PackageTargets.Single(); + + // Assert + await Assert.That(target.Direct).Contains("Direct.Package"); + await Assert.That(target.Direct).DoesNotContain("Persistence"); + await Assert.That(target.Owned).Contains("Direct.Package"); + await Assert.That(target.Owned).Contains("Transitive.Package"); + await Assert.That(target.Owned).DoesNotContain("Microsoft.EntityFrameworkCore"); + await Assert.That(target.ThroughReferences["Persistence"]).Contains("Microsoft.EntityFrameworkCore"); + await Assert + .That(target.ThroughReferences["Persistence"]) + .Contains("Microsoft.EntityFrameworkCore.Abstractions"); + await Assert.That(core.ResolvedPackages).Contains("Microsoft.EntityFrameworkCore"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Load_GivenAnSdkDerivedAssemblyName_MapsTheReferenceBackToItsProject() + { + // Arrange + // Restore keys the project node by assembly name; the SDK prefixes it with the namespace. + var root = CreateRepository( + /*lang=json,strict*/ + """ + { + "targets": { + "net10.0": { + "Purview.Architecture.Persistence/1.2.0": { + "type": "project", + "dependencies": { "Microsoft.EntityFrameworkCore": "9.0.0" } + }, + "Microsoft.EntityFrameworkCore/9.0.0": { "type": "package" } + } + } + } + """, + extraProject: "Persistence" + ); + + try + { + // Act + var core = ProjectGraph.Load(root, ["src"])["Core"]; + var target = core.PackageTargets.Single(); + + // Assert + await Assert.That(target.ThroughReferences.ContainsKey("Persistence")).IsTrue(); + await Assert.That(target.ThroughReferences["Persistence"]).Contains("Microsoft.EntityFrameworkCore"); + await Assert.That(target.ThroughReferences.ContainsKey("Purview.Architecture.Persistence")).IsFalse(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task Load_GivenNoAssetsFile_ReadsNoResolvedPackages() + { + // Arrange + var root = CreateRepository(assets: null); + + try + { + // Act + var projects = ProjectGraph.Load(root, ["src"]); + + // Assert + await Assert.That(projects["Core"].ResolvedPackages).IsEmpty(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task ForbiddenPackages_GivenATransitivePackage_ReportsIt() + { + // Arrange + // The csproj declares nothing; restore pulled the package in transitively. + var root = CreateRepository( + /*lang=json,strict*/ + """ + { + "targets": { + "net10.0": { + "Contoso.Forbidden.Provider/1.0.0": { "type": "package" } + } + } + } + """ + ); + + try + { + ArchitecturePolicy policy = new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "domain", + Purpose = "A responsibility declared by the project-graph fixture.", + MayDependOn = [], + MustNotDependOn = [], + ForbiddenPackagePrefixes = ["Contoso.Forbidden"], + }, + ], + }; + + var projects = ProjectGraph.Load(root, ["src"]); + ArchitecturePolicyEvaluator evaluator = new(policy, projects); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("Contoso.Forbidden.Provider"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// Writes a repository with one project and, optionally, its assets file. + /// The assets document, or to omit it. + /// An optional additional project name. + /// The repository root. + static string CreateRepository(string? assets, string? extraProject = null) + { + var root = Directory.CreateTempSubdirectory("PurviewProjectGraph").FullName; + var project = Path.Combine(root, "src", "Core"); + Directory.CreateDirectory(project); + + File.WriteAllText(Path.Combine(project, "Core.csproj"), """"""); + + if (extraProject is not null) + { + var extra = Path.Combine(root, "src", extraProject); + Directory.CreateDirectory(extra); + File.WriteAllText( + Path.Combine(extra, $"{extraProject}.csproj"), + """""" + ); + } + + if (assets is not null) + { + var obj = Path.Combine(project, "obj"); + Directory.CreateDirectory(obj); + File.WriteAllText(Path.Combine(obj, "project.assets.json"), assets); + } + + return root; + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/RuleCatalogueTests.cs b/src/tests/ArchitectureTesting.UnitTests/RuleCatalogueTests.cs new file mode 100644 index 0000000..e8beaea --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/RuleCatalogueTests.cs @@ -0,0 +1,86 @@ +using System.Reflection; + +namespace Purview.Architecture.Testing.UnitTests; + +/// +/// Guards the rule catalogue, which is the single authority the documentation and waivers use. +/// +public sealed class RuleCatalogueTests +{ + [Test] + public async Task Catalogue_GivenItsIdentifiers_HasNoDuplicates() + { + // Arrange + // A duplicate would make a waiver match one rule and the docs report another. + var identifiers = ArchitectureRules.All; + + // Act + var distinct = identifiers.Distinct(StringComparer.OrdinalIgnoreCase).Count(); + + // Assert + await Assert.That(distinct).IsEqualTo(identifiers.Count); + } + + [Test] + public async Task Catalogue_GivenThePublicConstants_DescribesEachExactlyOnce() + { + // Arrange + // A constant without a descriptor is a rule no document test can see; a descriptor without + // a constant is a rule no code can name. + var constants = typeof(ArchitectureRules) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.IsLiteral && field.FieldType == typeof(string)) + .Select(field => (string)field.GetRawConstantValue()!) + .ToArray(); + + // Act + var missing = constants.Except(ArchitectureRules.All, StringComparer.OrdinalIgnoreCase).ToArray(); + var extra = ArchitectureRules.All.Except(constants, StringComparer.OrdinalIgnoreCase).ToArray(); + + // Assert + await Assert.That(missing).IsEmpty().Because($"undescribed rules: {string.Join(", ", missing)}"); + await Assert.That(extra).IsEmpty().Because($"described but unnameable rules: {string.Join(", ", extra)}"); + } + + [Test] + public async Task Catalogue_GivenThePolicyGuards_MarksThemUnwaivable() + { + // Arrange + string[] guards = + [ + ArchitectureRules.PolicyIntegrity, + ArchitectureRules.GraphDiscovery, + ArchitectureRules.WaiverIntegrity, + ]; + + // Act + var waivable = guards.Where(ArchitectureRules.IsWaivable).ToArray(); + + // Assert + await Assert + .That(waivable) + .IsEmpty() + .Because($"a waiver excusing a guard would make the policy unfalsifiable: {string.Join(", ", waivable)}"); + } + + [Test] + public async Task Catalogue_GivenAnOrdinaryRule_MarksItWaivable() + { + // Arrange / Act + var waivable = ArchitectureRules.IsWaivable(ArchitectureRules.DependencyMatrix); + + // Assert + await Assert.That(waivable).IsTrue(); + } + + [Test] + public async Task Catalogue_GivenAnUnknownRule_TreatsItAsWaivable() + { + // Arrange + // An additional rule is waivable unless it declares otherwise, and it has no descriptor here. + var waivable = ArchitectureRules.IsWaivable("noConsoleLogging"); + + // Assert + await Assert.That(waivable).IsTrue(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/TypeNamespaceRuleTests.cs b/src/tests/ArchitectureTesting.UnitTests/TypeNamespaceRuleTests.cs new file mode 100644 index 0000000..13f40ba --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/TypeNamespaceRuleTests.cs @@ -0,0 +1,72 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the type-namespace rule against in-memory surfaces. +public sealed class TypeNamespaceRuleTests +{ + [Test] + public async Task TypeNamespace_GivenATypeOutsideTheRootNamespace_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("Order", ns: "Contoso.Legacy") + ); + + // Act + var violations = evaluator.TypeNamespace(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.TypeNamespace); + await Assert.That(violations[0].Message).Contains("outside the project's root namespace"); + } + + [Test] + public async Task TypeNamespace_GivenASubNamespace_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("Order", ns: "Purview.Core.Model") + ); + + // Act + var violations = evaluator.TypeNamespace(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeNamespace_GivenTheRootNamespace_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("Order", ns: "Purview.Core") + ); + + // Act + var violations = evaluator.TypeNamespace(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeNamespace_GivenAPrefixThatOnlyLooksSimilar_ReportsIt() + { + // Arrange + // 'Purview.CoreX' is not a sub-namespace of 'Purview.Core'. + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("Order", ns: "Purview.CoreX") + ); + + // Act + var violations = evaluator.TypeNamespace(); + + // Assert + await Assert.That(violations).HasSingleItem(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/TypeNamingRuleTests.cs b/src/tests/ArchitectureTesting.UnitTests/TypeNamingRuleTests.cs new file mode 100644 index 0000000..1d092e5 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/TypeNamingRuleTests.cs @@ -0,0 +1,155 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the type-naming rule against in-memory surfaces. +public sealed class TypeNamingRuleTests +{ + [Test] + public async Task TypeNaming_GivenAnInterfaceWithoutThePrefix_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("OrderRepository", APITypeKind.Interface) + ); + + // Act + var violations = evaluator.TypeNaming(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.TypeNaming); + await Assert.That(violations[0].Message).Contains("does not start with 'I'"); + } + + [Test] + public async Task TypeNaming_GivenAnInterfaceWithThePrefix_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("IOrderRepository", APITypeKind.Interface) + ); + + // Act + var violations = evaluator.TypeNaming(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeNaming_GivenAnAttributeWithoutItsSuffix_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("Audited", baseType: "System.Attribute") + ); + + // Act + var violations = evaluator.TypeNaming(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("does not end with 'Attribute'"); + } + + [Test] + public async Task TypeNaming_GivenAnExceptionWithoutItsSuffix_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("OrderRejected", baseType: "System.Exception") + ); + + // Act + var violations = evaluator.TypeNaming(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("does not end with 'Exception'"); + } + + [Test] + public async Task TypeNaming_GivenADiscouragedSuffix_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator(TypeRuleFixture.Rules, TypeRuleFixture.Type("OrderManager")); + + // Act + var violations = evaluator.TypeNaming(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("'Manager'"); + } + + [Test] + public async Task TypeNaming_GivenTypeRulesOff_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + new TypeRules { Enabled = false }, + TypeRuleFixture.Type("OrderRepository", APITypeKind.Interface) + ); + + // Act + var violations = evaluator.TypeNaming(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeNaming_GivenAnUngovernedCategory_ReportsNothing() + { + // Arrange + // Core is "shared"; the policy governs only "domain". + TypeRules rules = new() + { + Enabled = true, + AppliesTo = new TypeRuleScope { Categories = ["domain"] }, + }; + + var evaluator = TypeRuleFixture.Evaluator( + rules, + TypeRuleFixture.Type("OrderRepository", APITypeKind.Interface) + ); + + // Act + var violations = evaluator.TypeNaming(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeNaming_GivenAGeneratedType_IsSkippedByDefault() + { + // Arrange + APIType generated = new( + "Core", + "Purview.Core", + "GeneratedManager", + "Purview.Core.GeneratedManager", + APITypeKind.Class, + true, + false, + true, + false, + null, + [], + [], + [new APIAttribute("System.CodeDom.Compiler.GeneratedCodeAttribute", "tool")] + ); + + var evaluator = TypeRuleFixture.Evaluator(TypeRuleFixture.Rules, generated); + + // Act + var violations = evaluator.TypeNaming(); + + // Assert + await Assert.That(violations).IsEmpty(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/TypeRuleFixture.cs b/src/tests/ArchitectureTesting.UnitTests/TypeRuleFixture.cs new file mode 100644 index 0000000..cd3c54a --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/TypeRuleFixture.cs @@ -0,0 +1,85 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Builds in-memory type surfaces for the type-rule tests. +static class TypeRuleFixture +{ + /// The default enabled settings, before a test overrides a field. + public static TypeRules Rules => new() { Enabled = true }; + + /// Builds a public type in the Core project. + public static APIType Type( + string name, + APITypeKind kind = APITypeKind.Class, + bool isPublic = true, + bool isAbstract = false, + bool isSealed = true, + string ns = "Purview.Core", + string? baseType = null, + IReadOnlyList? members = null, + IReadOnlyList? attributes = null, + string project = "Core", + IReadOnlyList? interfaces = null + ) => + new( + project, + ns, + name, + $"{ns}.{name}", + kind, + isPublic, + isAbstract, + isSealed, + false, + baseType, + interfaces ?? [], + members ?? [], + attributes ?? [] + ); + + /// Builds a public member. + public static APIMember Member( + string name, + APIMemberKind kind = APIMemberKind.Method, + string typeName = "System.Void", + bool isStatic = false, + IReadOnlyList? attributes = null + ) => new(name, kind, typeName, isStatic, attributes ?? []); + + /// Builds an evaluator over one governed project. + public static ArchitecturePolicyEvaluator Evaluator(TypeRules rules, params APIType[] types) + { + ArchitecturePolicy policy = new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "shared", + Purpose = "A responsibility declared by the type-rule fixture.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + TypeRules = rules, + }; + + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "/repo/src/Core/Core.csproj", [], [], false) { RootNamespace = "Purview.Core" }, + }; + + Dictionary surfaces = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "Core", [], types), + }; + + return new ArchitecturePolicyEvaluator( + policy, + projects, + new Dictionary(), + apiSurfaces: surfaces + ); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/TypeShapeRuleTests.cs b/src/tests/ArchitectureTesting.UnitTests/TypeShapeRuleTests.cs new file mode 100644 index 0000000..bb65d27 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/TypeShapeRuleTests.cs @@ -0,0 +1,149 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the type-shape rule against in-memory surfaces. +public sealed class TypeShapeRuleTests +{ + [Test] + public async Task TypeShape_GivenAnUnsealedPublicClass_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("OrderService", isSealed: false) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.TypeShape); + await Assert.That(violations[0].Message).Contains("Seal it"); + } + + [Test] + public async Task TypeShape_GivenAnAbstractClass_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("OrderServiceBase", isAbstract: true, isSealed: false) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeShape_GivenAStaticClass_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("OrderRules", isAbstract: true, isSealed: true) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeShape_GivenAPublicInstanceField_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type( + "Order", + members: [TypeRuleFixture.Member("Status", APIMemberKind.Field, "System.String")] + ) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("public field 'Status'"); + } + + [Test] + public async Task TypeShape_GivenAStaticField_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type( + "OrderRules", + isAbstract: true, + isSealed: true, + members: [TypeRuleFixture.Member("Empty", APIMemberKind.Field, "System.String", isStatic: true)] + ) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeShape_GivenAMutableCollectionProperty_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type( + "Order", + members: + [ + TypeRuleFixture.Member( + "Lines", + APIMemberKind.Property, + "System.Collections.Generic.List" + ), + ] + ) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("IReadOnlyList"); + } + + [Test] + public async Task TypeShape_GivenAReadOnlyCollectionProperty_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type( + "Order", + members: + [ + TypeRuleFixture.Member( + "Lines", + APIMemberKind.Property, + "System.Collections.Generic.IReadOnlyList" + ), + ] + ) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).IsEmpty(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/TypeShapeWaiverTests.cs b/src/tests/ArchitectureTesting.UnitTests/TypeShapeWaiverTests.cs new file mode 100644 index 0000000..e263548 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/TypeShapeWaiverTests.cs @@ -0,0 +1,121 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// Exercises the remaining type-shape checks and the waiver path. +public sealed class TypeShapeWaiverTests +{ + [Test] + public async Task TypeShape_GivenAUtilityClassThatIsNotStatic_ReportsIt() + { + // Arrange + // Only static members and no public constructor: the class should be static. + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type( + "OrderFormatter", + members: [TypeRuleFixture.Member("Format", isStatic: true, typeName: "System.String")] + ) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("Declare it static"); + } + + [Test] + public async Task TypeShape_GivenAUtilityClassWithAPublicConstructor_ReportsNothing() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type( + "OrderFormatter", + members: + [ + TypeRuleFixture.Member(".ctor", APIMemberKind.Constructor), + TypeRuleFixture.Member("Format", isStatic: true, typeName: "System.String"), + ] + ) + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task TypeShape_GivenAnExceptionNameThatIsNotAnException_ReportsIt() + { + // Arrange + var evaluator = TypeRuleFixture.Evaluator( + TypeRuleFixture.Rules, + TypeRuleFixture.Type("OrderException", baseType: "System.Object") + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("does not derive from System.Exception"); + } + + [Test] + public async Task TypeShape_GivenAWaiver_ExcusesTheRuleForTheProject() + { + // Arrange + ArchitecturePolicy policy = new() + { + Version = 3, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "shared", + Purpose = "A responsibility declared by the type-rule fixture.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + TypeRules = TypeRuleFixture.Rules, + Waivers = + [ + new PolicyWaiver + { + Project = "Core", + Rules = [ArchitectureRules.TypeShape], + Reason = "The proxy requires an inheritable entity, and the waiver records that decision.", + ReviewedOn = "2026-10-18", + }, + ], + }; + + Dictionary projects = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "/repo/src/Core/Core.csproj", [], [], false) { RootNamespace = "Purview.Core" }, + }; + + Dictionary surfaces = new(StringComparer.OrdinalIgnoreCase) + { + ["Core"] = new("Core", "Core", [], [TypeRuleFixture.Type("Order", isSealed: false)]), + }; + + ArchitecturePolicyEvaluator evaluator = new( + policy, + projects, + new Dictionary(), + apiSurfaces: surfaces + ); + + // Act + var violations = evaluator.TypeShape(); + + // Assert + await Assert.That(violations).IsEmpty(); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/WaiverExpiryTests.cs b/src/tests/ArchitectureTesting.UnitTests/WaiverExpiryTests.cs new file mode 100644 index 0000000..c0611f1 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/WaiverExpiryTests.cs @@ -0,0 +1,174 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// +/// Exercises waiverPolicy.maxAgeDays and the waiver integrity checks around it. +/// +/// +/// The review date is what turns "reviewed" from a note into an obligation. The clock is pinned so +/// the tests state an age in days rather than depending on when the suite runs. +/// +public sealed class WaiverExpiryTests +{ + static readonly DateOnly Today = new(2026, 10, 18); + + static Responsibility Core => + new() + { + Name = "Core", + Category = "domain", + Purpose = "The fundamental domain model and its rules.", + MayDependOn = [], + MustNotDependOn = [], + }; + + static ArchitecturePolicy Policy(int? maxAgeDays, params PolicyWaiver[] waivers) => + new() + { + Version = 3, + Responsibilities = [Core], + WaiverPolicy = new WaiverPolicySettings { MaxAgeDays = maxAgeDays }, + Waivers = waivers, + }; + + static PolicyWaiver Waiver(string? reviewedOn, string rule = ArchitectureRules.Naming) => + new() + { + Project = "Core", + Rules = [rule], + Reason = "A reviewed departure that is long enough to satisfy the schema's minimum length.", + ReviewedOn = reviewedOn ?? string.Empty, + }; + + static ArchitecturePolicyEvaluator Evaluator(ArchitecturePolicy policy) => + new( + policy, + new Dictionary { ["Core"] = new("Core", "/repo/src/Core/Core.csproj", [], [], false) }, + new Dictionary(), + today: Today + ); + + [Test] + public async Task WaiverIntegrity_GivenAWaiverOlderThanTheMaximum_ReportsIt() + { + // Arrange + // 31 days old against a 30-day maximum. + var evaluator = Evaluator(Policy(30, Waiver("2026-09-17"))); + + // Act + var violations = evaluator.WaiverIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("31 days ago"); + await Assert.That(violations[0].Message).Contains("beyond waiverPolicy.maxAgeDays of 30"); + } + + [Test] + public async Task WaiverIntegrity_GivenAWaiverWithinTheMaximum_ReportsNothing() + { + // Arrange + var evaluator = Evaluator(Policy(30, Waiver("2026-09-18"))); + + // Act + var violations = evaluator.WaiverIntegrity(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task WaiverIntegrity_GivenNoReviewDateWithExpiryOn_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(30, Waiver(reviewedOn: null))); + + // Act + var violations = evaluator.WaiverIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("has no reviewedOn date"); + } + + [Test] + public async Task WaiverIntegrity_GivenNoReviewDateWithExpiryOff_ReportsNothing() + { + // Arrange + // Expiry is opt-in: a policy that has not adopted it still accepts an undated waiver. + var evaluator = Evaluator(Policy(maxAgeDays: null, Waiver(reviewedOn: null))); + + // Act + var violations = evaluator.WaiverIntegrity(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task WaiverIntegrity_GivenAReviewDateInTheFuture_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(30, Waiver("2026-10-19"))); + + // Act + var violations = evaluator.WaiverIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("in the future"); + } + + [Test] + public async Task WaiverIntegrity_GivenAMalformedReviewDate_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(maxAgeDays: null, Waiver("18/10/2026"))); + + // Act + var violations = evaluator.WaiverIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("not a date in the form yyyy-MM-dd"); + } + + [Test] + public async Task WaiverIntegrity_GivenAWaiverForAnUnwaivableRule_ReportsIt() + { + // Arrange + // A waiver that names a policy guard would look meaningful and excuse nothing. + var evaluator = Evaluator(Policy(30, Waiver("2026-10-18", ArchitectureRules.PolicyIntegrity))); + + // Act + var violations = evaluator.WaiverIntegrity(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("cannot be waived"); + } + + [Test] + public async Task WaiverReviewer_GivenEveryWaiver_ReportsOneReviewInPolicyOrder() + { + // Arrange + var policy = Policy( + 30, + Waiver("2026-10-18"), + new PolicyWaiver + { + Project = "Core", + Rules = [ArchitectureRules.Naming], + Reason = "A second reviewed departure that is long enough to satisfy the schema's minimum length.", + ReviewedOn = "2026-08-01", + } + ); + + // Act + var reviews = WaiverReviewer.Review(policy, Today); + + // Assert + await Assert.That(reviews.Count).IsEqualTo(2); + await Assert.That(reviews[0].IsStale).IsFalse(); + await Assert.That(reviews[1].IsStale).IsTrue(); + } +} diff --git a/src/tests/Repository.UnitTests/ArchitectureTests.cs b/src/tests/Repository.UnitTests/ArchitectureTests.cs index fe1ad73..bf3666d 100644 --- a/src/tests/Repository.UnitTests/ArchitectureTests.cs +++ b/src/tests/Repository.UnitTests/ArchitectureTests.cs @@ -13,4 +13,37 @@ namespace Purview.Architecture.Repository; /// on every run. /// [InheritsTests] -public sealed class ArchitectureTests : PurviewArchitectureTests; +public sealed class ArchitectureTests : PurviewArchitectureTests +{ + [Test] + public async Task APISurfaces_GivenEveryProductionProject_IsPresentAndPopulated() + { + // Arrange + // A production project missing here escapes every type-level rule silently, because the + // reader has no assembly to attribute to it. + var missing = Evaluator + .Projects.Values.Where(project => !project.IsTestProject) + .Where(project => !Evaluator.APISurfaces.ContainsKey(project.Name)) + .Select(project => project.Name) + .ToArray(); + + // Act + var publicTypes = Evaluator + .APISurfaces.Values.SelectMany(assembly => assembly.Types) + .Count(type => type.IsPublic); + + // Assert + await Assert + .That(missing) + .IsEmpty() + .Because( + "the dogfood test project must reference every production project, or its assembly is invisible: " + + string.Join(", ", missing) + ); + + await Assert + .That(publicTypes) + .IsGreaterThan(0) + .Because("an empty surface would make every type-level rule pass vacuously"); + } +} diff --git a/src/tests/Repository.UnitTests/PackageOwnershipTests.cs b/src/tests/Repository.UnitTests/PackageOwnershipTests.cs new file mode 100644 index 0000000..801063d --- /dev/null +++ b/src/tests/Repository.UnitTests/PackageOwnershipTests.cs @@ -0,0 +1,44 @@ +using Purview.Architecture.Testing; + +namespace Purview.Architecture.Repository; + +/// +/// Verifies package ownership against this repository's own restore graph: the CLI consumes the +/// package's dependencies through a permitted project reference, so it does not own them. +/// +public sealed class PackageOwnershipTests +{ + [Test] + public async Task OwnedPackages_GivenAPermittedProjectReference_ExcludesItsPackages() + { + // Arrange + var root = ArchitecturePolicy.RepositoryRoot(); + var projects = ProjectGraph.Load(root, ["src/src"]); + ArchitecturePolicyEvaluator evaluator = new(ArchitecturePolicy.Load(root), projects); + var cli = projects["Architecture.CLI"]; + + // Act + var owned = evaluator.OwnedPackages(cli); + + // Assert + await Assert + .That(cli.PackageTargets) + .IsNotEmpty() + .Because("the ownership check reads restore output, so the solution must be built first"); + + await Assert + .That(cli.ResolvedPackages) + .Contains("JsonSchema.Net") + .Because("the package arrives through the ArchitectureTesting project reference"); + + await Assert + .That(owned) + .DoesNotContain("JsonSchema.Net") + .Because("JsonSchema.Net is explained by the permitted ArchitectureTesting reference"); + + await Assert + .That(cli.PackageTargets[0].ThroughReferences.ContainsKey("ArchitectureTesting")) + .IsTrue() + .Because("restore keys the project by its assembly name, so the graph maps it back"); + } +} diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj index 2c1d737..d06a3c8 100644 --- a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -22,5 +22,11 @@ end to end on every test run. --> + + diff --git a/src/tests/Repository.UnitTests/RuleDocumentationTests.cs b/src/tests/Repository.UnitTests/RuleDocumentationTests.cs new file mode 100644 index 0000000..01c53e8 --- /dev/null +++ b/src/tests/Repository.UnitTests/RuleDocumentationTests.cs @@ -0,0 +1,73 @@ +using Purview.Architecture.Testing; + +namespace Purview.Architecture.Repository; + +/// Fails when the rule catalogue and the documentation drift apart. +/// +/// "Keep the docs in step" is a review instruction until it is a test. Every document that describes +/// the rule set must name every catalogue identifier in backticks, so adding a rule without +/// documenting it - or removing one without clearing the reference - fails the suite. +/// +public sealed class RuleDocumentationTests +{ + static readonly string[] Documents = + [ + "README.md", + "src/src/ArchitectureTesting/README.md", + "docs/Policy-Reference.md", + "skills/purview-architests/SKILL.md", + ]; + + [Test] + public async Task RuleCatalogue_GivenEveryDocument_ListsEveryRule() + { + // Arrange + var root = ArchitecturePolicy.RepositoryRoot(); + List missing = []; + + // Act + foreach (var document in Documents) + { + var path = Path.Combine(root, document); + + if (!File.Exists(path)) + { + missing.Add($"{document} (missing)"); + continue; + } + + var text = await File.ReadAllTextAsync(path); + + foreach (var rule in ArchitectureRules.All) + { + if (!text.Contains($"`{rule}`", StringComparison.Ordinal)) + missing.Add($"{document}: {rule}"); + } + } + + // Assert + await Assert + .That(missing) + .IsEmpty() + .Because("the rule catalogue and the documentation disagree: " + string.Join(", ", missing)); + } + + [Test] + public async Task RuleCatalogue_GivenADescriptor_HasAnAnchorAndASummary() + { + // Arrange + var incomplete = ArchitectureRules + .Catalogue.Where(descriptor => + string.IsNullOrWhiteSpace(descriptor.DocumentationAnchor) + || string.IsNullOrWhiteSpace(descriptor.Summary) + ) + .Select(descriptor => descriptor.Rule) + .ToArray(); + + // Act / Assert + await Assert + .That(incomplete) + .IsEmpty() + .Because($"a rule without a summary or anchor cannot be documented: {string.Join(", ", incomplete)}"); + } +}