From 0a03dac0cae53d399e89dfb1494ac80843a3ab9a Mon Sep 17 00:00:00 2001 From: AriusII Date: Tue, 22 Sep 2026 23:44:33 +0200 Subject: [PATCH 001/199] Apply repository formatting Promote the migration style rules that dotnet format can apply safely (IDE0008, IDE0011, IDE0040, IDE0044, IDE0055, IDE0065, IDE0090, IDE0161) from suggestion to warning, as the .editorconfig comment planned for a dedicated reformat pass, and apply dotnet format to the whole solution. EnforceCodeStyleInBuild and TreatWarningsAsErrors now keep the tree formatted; IDE0005 and IDE1006 keep their previous severities. No behavior change. --- .editorconfig | 26 ++--- .../Plugin/PluginClassRewriter.cs | 8 +- .../Plugin/PluginClassShapeCodeFixProvider.cs | 20 ++-- .../Usage/ExceptionGuardRewriter.cs | 2 +- ...nmanagedCallersOnlyGuardCodeFixProvider.cs | 36 +++--- .../Generation/LuaFunctionDuplicateState.cs | 2 +- .../Generation/LuaObjectBindingAnalyzer.cs | 46 ++++---- .../Plugin/CheatEnginePluginAnalyzer.cs | 40 +++---- .../Plugin/PluginCompilationState.cs | 2 +- .../Usage/ExceptionGuard.cs | 58 +++++----- .../PluginLifecycleAndOwnershipAnalyzer.cs | 16 +-- .../UnmanagedCallersOnlyGuardAnalyzer.cs | 2 +- .../WellKnown/SdkSymbolResolver.cs | 2 +- .../BoundedDebugEventObservationBuffer.cs | 2 +- .../Native/ClassicDebugEventDispatcher.cs | 6 +- .../AddressList/AddressListCalls.cs | 2 +- .../AddressList/AddressListMutations.cs | 2 +- .../Allocation/AllocatedRegion.cs | 2 +- .../Allocation/TargetMemoryAllocator.cs | 2 +- .../Assembly/InstructionDisassembler.cs | 6 +- .../Assembly/InstructionProfiles.cs | 2 +- .../Enums/CEEnumNames.cs | 4 +- .../EngineGlobalUnavailableException.cs | 2 +- .../Errors/EngineLuaException.cs | 2 +- .../Errors/EngineMarshallingException.cs | 4 +- .../Errors/EngineResourceHandoffException.cs | 4 +- .../Errors/EngineTargetIdentityException.cs | 2 +- .../Inspection/EngineInspection.cs | 42 +++---- .../Inspection/SymbolRegistry.cs | 8 +- .../Memory/HostMemory.cs | 24 ++-- .../Memory/MemoryLua.cs | 4 +- .../Memory/TargetMemory.cs | 32 +++--- .../Objects/StringLists.cs | 4 +- .../Processes/RuntimeHostOperations.cs | 4 +- .../Processes/RuntimeProcessOperations.cs | 4 +- .../Runtime/CheatEngineVersion.cs | 6 +- .../Scanning/Aob/AobScanOptions.cs | 4 +- .../Scanning/Values/MemoryScanSession.cs | 2 +- .../Scanning/Values/MemoryScanSessions.cs | 4 +- .../Targets/TargetSelection.cs | 2 +- libs/CheatEngine.SDK.Engine/Values/Address.cs | 2 +- .../Bootstrap/PluginHost.Lifecycle.cs | 14 +-- .../Bootstrap/PluginHost.cs | 4 +- .../Threading/MainThreadDispatcher.cs | 6 +- .../GlobalUsings.cs | 9 +- .../Protected/LuaBridgeContract.cs | 20 ++-- .../Callbacks/LuaHostSubscription.cs | 6 +- .../Callbacks/LuaHostSubscriptionRegistry.cs | 4 +- .../CompilerServices/LuaCallSupport.cs | 2 +- libs/CheatEngine.SDK.Lua/References/LuaRef.cs | 4 +- .../Registration/LuaRegistrationSet.cs | 2 +- .../Runtime/LuaHostBinding.cs | 2 +- .../CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs | 4 +- .../State/LuaState.Tables.cs | 2 +- .../Emit/EngineApiFileEmitter.cs | 3 +- .../Model/SpecFiles.cs | 25 ++-- .../Parsing/SpecFileParser.cs | 108 +++++++++--------- .../Emit/BootstrapEmitter.cs | 4 +- .../Parsing/EntryPointContractSymbols.cs | 2 +- .../EntryPointDeclaredDiagnosticIds.cs | 22 ++-- .../Parsing/EntryPointGeneratedIdentity.cs | 4 +- .../Emit/LuaObjectMembersFileEmitter.cs | 2 +- .../Model/LuaFunctionModel.cs | 2 +- .../Model/LuaFunctionTables.cs | 5 +- .../Model/LuaGlobalModel.cs | 2 +- .../Model/LuaGlobalTables.cs | 5 +- .../Model/LuaObjectMembersTables.cs | 5 +- .../Parsing/ContainingTypeParser.cs | 2 +- .../LuaBindingsDeclaredDiagnosticIds.cs | 18 +-- .../Parsing/LuaClassParser.cs | 18 +-- .../Parsing/LuaFunctionParser.cs | 6 +- .../Parsing/LuaGlobalParser.cs | 4 +- .../Parsing/LuaObjectMethodParser.cs | 48 ++++---- .../Parsing/LuaObjectPropertyParser.cs | 36 +++--- .../ProtectedOperationCatalogParser.cs | 8 +- .../GeneratedCodeText.cs | 8 +- .../HintNames.cs | 2 +- .../Parsing/ContainingTypeShape.cs | 2 +- .../LuaBindings/Parsing/Identifiers.cs | 2 +- .../Parsing/LuaClassGeneratedNames.cs | 14 +-- .../LuaBindings/Parsing/LuaFunctionShape.cs | 10 +- .../LuaBindings/Parsing/LuaGlobalShape.cs | 24 ++-- .../Parsing/LuaMarshallerResolver.cs | 24 ++-- .../LuaBindings/Parsing/LuaValueKindMapper.cs | 6 +- .../LuaEmit/LuaGlobalCallEmitter.cs | 2 +- .../LuaEmit/LuaGlobalCallModel.cs | 6 +- .../LuaEmit/LuaThunkEmitter.cs | 2 +- .../LuaEmit/LuaThunkModel.cs | 2 +- .../Shapes/PluginShape.cs | 10 +- .../Fixture/NativeAbiFixtureContractTests.cs | 4 +- .../Managed/PluginInitRecordTests.cs | 2 +- ...assicExportedFunctionsPrefixReaderTests.cs | 6 +- .../Native/ExportedFunctionsPrefixTests.cs | 12 +- .../LuaDirectApiBoundaryGuardTests.cs | 60 +++++----- .../Diagnostics/DiagnosticCatalogTests.cs | 32 +++--- .../Generation/LuaBindingAnalyzerTests.cs | 22 ++-- .../Infrastructure/CodeFixVerifier.cs | 3 +- .../LocalFrameworkReferences.cs | 4 +- .../LocalFrameworkReferencesTests.cs | 2 +- .../Infrastructure/RepositoryLayout.cs | 4 +- .../CallbackBenchmarks.cs | 2 +- .../EngineApiIncrementalBenchmarks.cs | 2 +- .../GlobalCallBenchmarks.cs | 2 +- .../MemoryScalarBenchmarks.cs | 10 +- .../Support/FakeHostRuntime.cs | 2 +- .../Allocation/AllocatedRegionTests.cs | 40 +++++-- ...uaTargetMemoryAllocationOperationsTests.cs | 2 +- .../Allocation/TargetMemoryAllocatorTests.cs | 63 ++++++++-- .../Assembly/AutoAssemblerPatcherTests.cs | 4 +- .../Inspection/EngineInspectionTests.cs | 10 +- .../RuntimeProcessOperationsTests.cs | 2 +- .../Scanning/MemoryScanSessionFactoryTests.cs | 4 +- .../Scanning/MemoryScanSessionTests.cs | 20 ++-- .../Support/EngineTest.cs | 2 +- .../Support/FakeHost.cs | 16 +-- .../Support/RecordingPlugin.cs | 8 +- .../HostProfileObservation.cs | 6 +- .../LiveProbeAuthorization.cs | 28 ++--- .../LiveProbeState.cs | 46 ++++++-- .../Program.cs | 10 +- .../Initialization/LuaApiBoundTableTests.cs | 2 +- .../Protected/LuaBridgeContractTests.cs | 2 +- .../RoundTrips/CallTests.cs | 8 +- .../Support/IlReader.cs | 2 +- .../Allocation/ZeroAllocationTests.cs | 6 +- .../Callbacks/LuaCallbackTests.cs | 5 +- .../Callbacks/LuaHostSubscriptionTests.cs | 24 ++-- .../Generated/MemoryBindings.cs | 2 +- .../Program.cs | 2 +- .../Generator/DiagnosticsTests.cs | 10 +- .../Generator/IncrementalityTests.cs | 8 +- .../Infrastructure/GeneratedAssembly.cs | 4 +- .../LocalFrameworkReferences.cs | 4 +- .../Infrastructure/ModelGraph.cs | 2 +- .../Parsing/SpecFileParserTests.cs | 8 +- .../Generator/ContractIdentityTests.cs | 6 +- .../Generator/IncrementalityTests.cs | 10 +- .../Generator/RealAssemblyCompilationTests.cs | 6 +- .../Infrastructure/GeneratorRun.cs | 6 +- .../Infrastructure/LoadedBootstrap.cs | 2 +- .../LocalFrameworkReferences.cs | 4 +- .../Infrastructure/ModelGraph.cs | 2 +- .../Model/BootstrapModelTests.cs | 25 +++- .../Generator/ContainingTypeTests.cs | 16 +-- .../Generator/DefaultVerifierTests.cs | 3 +- .../Generator/IncrementalityTests.cs | 14 ++- .../Generator/LuaFunctionOutputTests.cs | 8 +- .../Generator/LuaGlobalOutputTests.cs | 6 +- .../Generator/LuaObjectOutputTests.cs | 2 +- .../Generator/NoOutputTests.cs | 8 +- .../Generator/PartialMethodSignatureTests.cs | 18 +-- .../Infrastructure/GeneratorRun.cs | 6 +- .../LocalFrameworkReferences.cs | 4 +- .../Infrastructure/ModelGraph.cs | 2 +- .../Model/LuaFunctionTablesTests.cs | 8 +- .../Model/LuaGlobalTablesTests.cs | 3 +- .../SharedCode/HintNamesTests.cs | 2 +- .../SharedCode/LuaApiNamesTests.cs | 4 +- .../SharedCode/LuaGlobalCallEmitterTests.cs | 5 +- .../Generator/CatalogDiagnosticsTests.cs | 12 +- .../Generator/CatalogEmissionTests.cs | 2 +- .../NativeLua/NativeLuaProbe.cs | 2 +- .../ProjectDependencyDirectionTests.cs | 34 +++--- .../Infrastructure/EntryPointProbe.cs | 4 +- .../Infrastructure/PackagedUmbrellaFixture.cs | 11 +- .../Infrastructure/ProcessRunner.cs | 5 +- .../Infrastructure/RepositoryLayout.cs | 4 +- 167 files changed, 955 insertions(+), 774 deletions(-) diff --git a/.editorconfig b/.editorconfig index 22262b1b..c02c664f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -75,24 +75,22 @@ csharp_style_prefer_top_level_statements = false # blockers because Directory.Build.props treats warnings as errors. dotnet_diagnostic.IDE0079.severity = error -# Migration rules: surface the historical backlog in every Roslyn-aware editor -# and participate in code cleanup without breaking the currently dirty tree. -# Promote these to warning/error in a dedicated cleanup change once the existing -# source has been reformatted and explicit types have been introduced. +# Style rules enforced since the repository-wide reformat of the audit remediation branch +# (see .git-blame-ignore-revs). EnforceCodeStyleInBuild plus TreatWarningsAsErrors turns them into build errors, +# and CI also runs `dotnet format --verify-no-changes`. dotnet_diagnostic.IDE0007.severity = none -dotnet_diagnostic.IDE0008.severity = suggestion -dotnet_diagnostic.IDE0011.severity = suggestion +dotnet_diagnostic.IDE0008.severity = warning +dotnet_diagnostic.IDE0011.severity = warning dotnet_diagnostic.IDE0033.severity = suggestion -dotnet_diagnostic.IDE0040.severity = suggestion -dotnet_diagnostic.IDE0044.severity = suggestion -# The formatter still follows the tab contract above. Do not flood every open -# legacy file with a formatting diagnostic before the dedicated reformat pass. -dotnet_diagnostic.IDE0055.severity = none +dotnet_diagnostic.IDE0040.severity = warning +dotnet_diagnostic.IDE0044.severity = warning +dotnet_diagnostic.IDE0055.severity = warning dotnet_diagnostic.IDE0005.severity = suggestion -dotnet_diagnostic.IDE0065.severity = suggestion -dotnet_diagnostic.IDE0090.severity = suggestion +dotnet_diagnostic.IDE0065.severity = warning +dotnet_diagnostic.IDE0090.severity = warning dotnet_diagnostic.IDE0160.severity = none -dotnet_diagnostic.IDE0161.severity = suggestion +dotnet_diagnostic.IDE0161.severity = warning +# Naming fixes rename symbols, which dotnet format cannot apply safely; keep them as editor guidance. dotnet_diagnostic.IDE1006.severity = suggestion # Rider derives its own "var or explicit type" inspections from the shared diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs index 20d04ff8..e0e532e4 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassRewriter.cs @@ -57,8 +57,8 @@ public static ConstructorDeclarationSyntax MakePublic(ConstructorDeclarationSynt // 'private protected' and 'protected internal' are two keywords: drop the second one, but not what the // author wrote around it. Its comments move behind the modifier in front of it, which always exists. for (int next = IndexOfAccessibility(modifiers, first + 1); - next >= 0; - next = IndexOfAccessibility(modifiers, next)) + next >= 0; + next = IndexOfAccessibility(modifiers, next)) { SyntaxToken dropped = modifiers[next]; SyntaxToken previous = modifiers[next - 1]; @@ -79,7 +79,7 @@ private static SyntaxTriviaList FromFirstComment(SyntaxTriviaList trivia) foreach (SyntaxTrivia item in trivia) { if (kept.Count > 0 || - !(item.IsKind(SyntaxKind.WhitespaceTrivia) || item.IsKind(SyntaxKind.EndOfLineTrivia))) + !(item.IsKind(SyntaxKind.WhitespaceTrivia) || item.IsKind(SyntaxKind.EndOfLineTrivia))) { kept = kept.Add(item); } @@ -111,7 +111,7 @@ private static int IndexOfAccessibility(SyntaxTokenList modifiers, int start) for (int index = start; index < modifiers.Count; index++) { if (modifiers[index].Kind() is SyntaxKind.PublicKeyword or SyntaxKind.InternalKeyword - or SyntaxKind.ProtectedKeyword or SyntaxKind.PrivateKeyword) + or SyntaxKind.ProtectedKeyword or SyntaxKind.PrivateKeyword) { return index; } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs index 4926f9a2..589468a5 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Plugin/PluginClassShapeCodeFixProvider.cs @@ -80,10 +80,10 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) foreach (Diagnostic diagnostic in context.Diagnostics) { if (!diagnostic.Properties.TryGetValue(DiagnosticProperties.PluginClassProblem, out string? problemName) - || !Enum.TryParse(problemName, out PluginShapeIssues problem) - || root.FindToken(diagnostic.Location.SourceSpan.Start).Parent - ?.FirstAncestorOrSelf() is not { } declaration - || semanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not { } type) + || !Enum.TryParse(problemName, out PluginShapeIssues problem) + || root.FindToken(diagnostic.Location.SourceSpan.Start).Parent + ?.FirstAncestorOrSelf() is not { } declaration + || semanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not { } type) { continue; } @@ -117,7 +117,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) { if (reference.GetSyntax(cancellationToken) is not TypeDeclarationSyntax part - || solution.GetDocument(reference.SyntaxTree) is not { } document) + || solution.GetDocument(reference.SyntaxTree) is not { } document) { continue; } @@ -243,11 +243,11 @@ private static bool CanOmitEveryArgument(IMethodSymbol constructor) foreach (IMethodSymbol constructor in type.InstanceConstructors) { if (!constructor.Parameters.IsEmpty - || constructor.IsImplicitlyDeclared - || constructor.DeclaringSyntaxReferences.IsEmpty - || constructor.DeclaringSyntaxReferences[0] is not { } reference - || reference.GetSyntax(cancellationToken) is not ConstructorDeclarationSyntax syntax - || solution.GetDocument(reference.SyntaxTree) is not { } document) + || constructor.IsImplicitlyDeclared + || constructor.DeclaringSyntaxReferences.IsEmpty + || constructor.DeclaringSyntaxReferences[0] is not { } reference + || reference.GetSyntax(cancellationToken) is not ConstructorDeclarationSyntax syntax + || solution.GetDocument(reference.SyntaxTree) is not { } document) { continue; } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs index 345a5f11..b71d2474 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/ExceptionGuardRewriter.cs @@ -57,7 +57,7 @@ public static BlockSyntax Guard(ArrowExpressionClauseSyntax expressionBody, Synt int statementPart = IndexAfterLastComment(afterSemicolon); SyntaxTriviaList withStatement = Slice(afterSemicolon, 0, statementPart); if (statementPart > 0 && afterSemicolon[statementPart - 1].IsKind(SyntaxKind.SingleLineCommentTrivia)) - // The closing brace of the try block follows: it must not end up inside the comment. + // The closing brace of the try block follows: it must not end up inside the comment. { withStatement = withStatement.Add(endOfLine); } diff --git a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs index b95c46bb..46d12ee0 100644 --- a/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs +++ b/analyzers/CheatEngine.SDK.Analyzers.CodeFixes/Usage/UnmanagedCallersOnlyGuardCodeFixProvider.cs @@ -64,8 +64,8 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) { MethodDeclarationSyntax? declaration = FindDeclaration(root, diagnostic.Location.SourceSpan); if (declaration is null - || semanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not IMethodSymbol method - || !HasKnownFailureConvention(method)) + || semanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not IMethodSymbol method + || !HasKnownFailureConvention(method)) { continue; } @@ -107,8 +107,8 @@ private static async Task WrapAsync(Document document, MethodDeclarati SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); if (root is null || - semanticModel?.GetDeclaredSymbol(declaration, cancellationToken) is not IMethodSymbol method - || !HasKnownFailureConvention(method)) + semanticModel?.GetDeclaredSymbol(declaration, cancellationToken) is not IMethodSymbol method + || !HasKnownFailureConvention(method)) { return document; } @@ -136,25 +136,25 @@ private static async Task WrapAsync(Document document, MethodDeclarati private static bool HasKnownFailureConvention(IMethodSymbol method) { if (!method.IsStatic - || method.IsGenericMethod - || method.DeclaredAccessibility != Accessibility.Public - || !string.Equals(method.Name, "CEPluginInitialize", StringComparison.Ordinal) - || method.ReturnsByRef - || method.ReturnsByRefReadonly - || method.ReturnType.SpecialType != SpecialType.System_Int32 - || method.Parameters.Length != 2 - || method.Parameters[0].RefKind != RefKind.None - || method.Parameters[0].Type.SpecialType != SpecialType.System_IntPtr - || method.Parameters[1].RefKind != RefKind.None - || method.Parameters[1].Type.SpecialType != SpecialType.System_Int32) + || method.IsGenericMethod + || method.DeclaredAccessibility != Accessibility.Public + || !string.Equals(method.Name, "CEPluginInitialize", StringComparison.Ordinal) + || method.ReturnsByRef + || method.ReturnsByRefReadonly + || method.ReturnType.SpecialType != SpecialType.System_Int32 + || method.Parameters.Length != 2 + || method.Parameters[0].RefKind != RefKind.None + || method.Parameters[0].Type.SpecialType != SpecialType.System_IntPtr + || method.Parameters[1].RefKind != RefKind.None + || method.Parameters[1].Type.SpecialType != SpecialType.System_Int32) { return false; } INamedTypeSymbol containingType = method.ContainingType; return string.Equals(containingType.Name, "CESDK", StringComparison.Ordinal) - && containingType.ContainingType is null - && string.Equals(containingType.ContainingNamespace.ToDisplayString(), "CESDK", - StringComparison.Ordinal); + && containingType.ContainingType is null + && string.Equals(containingType.ContainingNamespace.ToDisplayString(), "CESDK", + StringComparison.Ordinal); } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs index 5b73fe87..09327583 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaFunctionDuplicateState.cs @@ -65,7 +65,7 @@ public void Report(CompilationAnalysisContext context) private string LuaNameFor(ConcurrentQueue<(string MethodName, Location Location)> members) { foreach (KeyValuePair<(string ContainingType, string LuaName), - ConcurrentQueue<(string MethodName, Location Location)>> pair in _candidates) + ConcurrentQueue<(string MethodName, Location Location)>> pair in _candidates) { if (ReferenceEquals(pair.Value, members)) { diff --git a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs index 7afb0b68..fe704b6f 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Generation/LuaObjectBindingAnalyzer.cs @@ -75,7 +75,7 @@ private static void AnalyzeType(SymbolAnalysisContext context, LuaObjectContract { INamedTypeSymbol type = (INamedTypeSymbol) context.Symbol; if (symbols.LuaClassAttribute is not null - && FindAttribute(type, symbols.LuaClassAttribute) is { } luaClassAttribute) + && FindAttribute(type, symbols.LuaClassAttribute) is { } luaClassAttribute) { string problem = LuaClassProblem(type, luaClassAttribute, context.CancellationToken); if (problem.Length > 0) @@ -98,7 +98,7 @@ private static void AnalyzeMethod(SymbolAnalysisContext context, LuaObjectContra { IMethodSymbol method = (IMethodSymbol) context.Symbol; if (symbols.LuaMethodAttribute is not null - && FindAttribute(method, symbols.LuaMethodAttribute) is { } luaMethodAttribute) + && FindAttribute(method, symbols.LuaMethodAttribute) is { } luaMethodAttribute) { string problem = LuaMethodProblem(method, luaMethodAttribute, symbols, context.CancellationToken); if (problem.Length > 0) @@ -112,7 +112,7 @@ private static void AnalyzeProperty(SymbolAnalysisContext context, LuaObjectCont { IPropertySymbol property = (IPropertySymbol) context.Symbol; if (symbols.LuaPropertyAttribute is null - || FindAttribute(property, symbols.LuaPropertyAttribute) is not { } luaPropertyAttribute) + || FindAttribute(property, symbols.LuaPropertyAttribute) is not { } luaPropertyAttribute) { return; } @@ -148,12 +148,12 @@ private static void ReportLuaClassIdentityCollisions(SymbolAnalysisContext conte foreach (ISymbol member in type.GetMembers()) { if (member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator } method - && method.Name is "op_Equality" or "op_Inequality") + && method.Name is "op_Equality" or "op_Inequality") { ReportCollision(context, member, "operator " + - (string.Equals(method.Name, "op_Equality", StringComparison.Ordinal) - ? "==" - : "!=")); + (string.Equals(method.Name, "op_Equality", StringComparison.Ordinal) + ? "==" + : "!=")); } } @@ -165,8 +165,8 @@ private static void ReportLuaClassIdentityCollisions(SymbolAnalysisContext conte foreach (IMethodSymbol constructor in type.InstanceConstructors) { if (constructor.Parameters.Length == 1 - && constructor.Parameters[0].RefKind == RefKind.None - && SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, ceObject)) + && constructor.Parameters[0].RefKind == RefKind.None + && SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, ceObject)) { ReportCollision(context, constructor, type.Name + "(CEObject)"); } @@ -201,7 +201,7 @@ private static void ReportLuaFunctionIdentityCollisions(SymbolAnalysisContext co foreach (ISymbol member in type.GetMembers()) { if (member is not IMethodSymbol method - || FindAttribute(method, symbols.LuaFunctionAttribute) is not { } attribute) + || FindAttribute(method, symbols.LuaFunctionAttribute) is not { } attribute) { continue; } @@ -250,7 +250,7 @@ private static void ReportLuaGlobalIdentityCollisions(SymbolAnalysisContext cont foreach (ISymbol member in type.GetMembers()) { if (member is not IMethodSymbol method - || FindAttribute(method, symbols.LuaGlobalAttribute) is not { } attribute) + || FindAttribute(method, symbols.LuaGlobalAttribute) is not { } attribute) { continue; } @@ -523,7 +523,7 @@ private static string LuaClassProblemForMember(INamedTypeSymbol containingType, CancellationToken cancellationToken) { if (symbols.LuaClassAttribute is null - || FindAttribute(containingType, symbols.LuaClassAttribute) is not { } luaClassAttribute) + || FindAttribute(containingType, symbols.LuaClassAttribute) is not { } luaClassAttribute) { return "the containing type must carry [LuaClass]"; } @@ -585,7 +585,7 @@ private static bool IsBodylessPartialProperty(IPropertySymbol property, Cancella } if (!hasPartialModifier || declaration.AccessorList is null || - declaration.AccessorList.Accessors.Count == 0) + declaration.AccessorList.Accessors.Count == 0) { return false; } @@ -598,8 +598,8 @@ private static bool IsBodylessPartialProperty(IPropertySymbol property, Cancella foreach (AccessorDeclarationSyntax accessor in declaration.AccessorList.Accessors) { if (accessor.Kind() is not SyntaxKind.GetAccessorDeclaration and not SyntaxKind.SetAccessorDeclaration - || accessor.Body is not null || accessor.ExpressionBody is not null - || !HasSupportedAccessorModifiers(accessor)) + || accessor.Body is not null || accessor.ExpressionBody is not null + || !HasSupportedAccessorModifiers(accessor)) { return false; } @@ -614,7 +614,7 @@ private static bool HasSupportedAccessorModifiers(AccessorDeclarationSyntax acce foreach (SyntaxToken modifier in accessor.Modifiers) { if (modifier.Kind() is not (SyntaxKind.PublicKeyword or SyntaxKind.PrivateKeyword - or SyntaxKind.ProtectedKeyword or SyntaxKind.InternalKeyword)) + or SyntaxKind.ProtectedKeyword or SyntaxKind.InternalKeyword)) { return false; } @@ -626,15 +626,15 @@ private static bool HasSupportedAccessorModifiers(AccessorDeclarationSyntax acce private static bool IsScalar(ITypeSymbol type, bool allowReadOnlySpan, INamedTypeSymbol? readOnlySpan) { if (type.SpecialType is SpecialType.System_Int32 or SpecialType.System_Int64 or SpecialType.System_Single - or SpecialType.System_Double or SpecialType.System_Boolean or SpecialType.System_UIntPtr - or SpecialType.System_String) + or SpecialType.System_Double or SpecialType.System_Boolean or SpecialType.System_UIntPtr + or SpecialType.System_String) { return true; } if (!allowReadOnlySpan || readOnlySpan is null || type is not INamedTypeSymbol { IsGenericType: true } named - || !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, readOnlySpan) - || named.TypeArguments.Length != 1) + || !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, readOnlySpan) + || named.TypeArguments.Length != 1) { return false; } @@ -746,8 +746,8 @@ public INamedTypeSymbol? CEObject } = ceObject; public bool HasAnyLuaObjectAnnotation => LuaClassAttribute is not null || LuaMethodAttribute is not null - || LuaPropertyAttribute is not null || - LuaFunctionAttribute is not null - || LuaGlobalAttribute is not null; + || LuaPropertyAttribute is not null || + LuaFunctionAttribute is not null + || LuaGlobalAttribute is not null; } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs index f7e998f0..65242eab 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/CheatEnginePluginAnalyzer.cs @@ -86,8 +86,8 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) // CESDK.CESDK to the author, which CESDK0003 validates at compilation end. bool? entryPointIsGenerated = null; if (context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue(GenerateEntryPointKey, - out string? raw) - && bool.TryParse(raw, out bool generate)) + out string? raw) + && bool.TryParse(raw, out bool generate)) { entryPointIsGenerated = generate; } @@ -128,7 +128,7 @@ private static void AnalyzeNamedType( // The attribute targets classes only: on anything else the compiler already reports CS0592. if (type.TypeKind != TypeKind.Class || - FindAttribute(type, symbols.PluginAttribute) is not { } attribute) + FindAttribute(type, symbols.PluginAttribute) is not { } attribute) { return; } @@ -215,20 +215,20 @@ private static bool IsManualBootstrap(INamedTypeSymbol type) foreach (ISymbol member in type.GetMembers("CEPluginInitialize")) { if (member is not IMethodSymbol - { - MethodKind: MethodKind.Ordinary, - IsStatic: true, - IsGenericMethod: false, - DeclaredAccessibility: Accessibility.Public, - ReturnsByRef: false, - ReturnsByRefReadonly: false, - ReturnType.SpecialType: SpecialType.System_Int32, - Parameters: - [ - { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_IntPtr }, - { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_Int32 } - ] - }) + { + MethodKind: MethodKind.Ordinary, + IsStatic: true, + IsGenericMethod: false, + DeclaredAccessibility: Accessibility.Public, + ReturnsByRef: false, + ReturnsByRefReadonly: false, + ReturnType.SpecialType: SpecialType.System_Int32, + Parameters: + [ + { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_IntPtr }, + { RefKind: RefKind.None, Type.SpecialType: SpecialType.System_Int32 } + ] + }) { continue; } @@ -265,9 +265,9 @@ private static void AnalyzeNamespaceDeclaration(SyntaxNodeAnalysisContext contex // A nested declaration is under 'CESDK' exactly when its outermost declaration is: one report per outermost one. if (declaration.Parent is not CompilationUnitSyntax - || context.SemanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not INamespaceSymbol - declared - || !IsUnderReservedRoot(declared, context.CancellationToken)) + || context.SemanticModel.GetDeclaredSymbol(declaration, context.CancellationToken) is not INamespaceSymbol + declared + || !IsUnderReservedRoot(declared, context.CancellationToken)) { return; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs index 146e92da..13260a17 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Plugin/PluginCompilationState.cs @@ -57,7 +57,7 @@ public void Report(CompilationAnalysisContext context) { int pluginClassCount = _pluginClasses.Count; if (pluginClassCount == 0) - // Not a plugin assembly (the SDK's own libraries, a helper library): both rules are about plugins. + // Not a plugin assembly (the SDK's own libraries, a helper library): both rules are about plugins. { return; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs index 4837f102..b9db1a10 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/ExceptionGuard.cs @@ -113,16 +113,16 @@ private static bool IsGuardTry(ITryOperation tryOperation, INamedTypeSymbol exce } return hasCatchAll - && (tryOperation.Finally is null - || !ContainsThrow(tryOperation.Finally, doesNotReturnAttribute, environmentType)); + && (tryOperation.Finally is null + || !ContainsThrow(tryOperation.Finally, doesNotReturnAttribute, environmentType)); } // 'catch { }' has the exception type System.Object; 'catch (Exception)' names the root of the hierarchy. private static bool IsCatchAll(ICatchClauseOperation catchClause, INamedTypeSymbol exceptionType) { return catchClause.Filter is null - && (catchClause.ExceptionType.SpecialType == SpecialType.System_Object - || SymbolEqualityComparer.Default.Equals(catchClause.ExceptionType, exceptionType)); + && (catchClause.ExceptionType.SpecialType == SpecialType.System_Object + || SymbolEqualityComparer.Default.Equals(catchClause.ExceptionType, exceptionType)); } private static bool ContainsThrow(IOperation block, INamedTypeSymbol? doesNotReturnAttribute, @@ -131,8 +131,8 @@ private static bool ContainsThrow(IOperation block, INamedTypeSymbol? doesNotRet foreach (IOperation descendant in block.Descendants()) { if (descendant.Kind == OperationKind.Throw - || (descendant is IInvocationOperation invocation - && NeverReturnsByThrowing(invocation.TargetMethod, doesNotReturnAttribute, environmentType))) + || (descendant is IInvocationOperation invocation + && NeverReturnsByThrowing(invocation.TargetMethod, doesNotReturnAttribute, environmentType))) { return true; } @@ -148,7 +148,7 @@ private static bool NeverReturnsByThrowing(IMethodSymbol method, INamedTypeSymbo INamedTypeSymbol? environmentType) { if (doesNotReturnAttribute is null - || SymbolEqualityComparer.Default.Equals(method.ContainingType, environmentType)) + || SymbolEqualityComparer.Default.Equals(method.ContainingType, environmentType)) { return false; } @@ -167,11 +167,13 @@ private static bool NeverReturnsByThrowing(IMethodSymbol method, INamedTypeSymbo private static bool AreTrivialDeclarations(IVariableDeclarationGroupOperation group) { foreach (IVariableDeclarationOperation declaration in group.Declarations) - foreach (IVariableDeclaratorOperation declarator in declaration.Declarators) { - if (declarator.Initializer is { } initializer && !IsTriviallyNonThrowing(initializer.Value)) + foreach (IVariableDeclaratorOperation declarator in declaration.Declarators) { - return false; + if (declarator.Initializer is { } initializer && !IsTriviallyNonThrowing(initializer.Value)) + { + return false; + } } } @@ -197,7 +199,7 @@ private static bool IsTriviallyNonThrowing(IOperation value) } => true, IConversionOperation conversion => IsNonThrowingConversion(conversion) && - IsTriviallyNonThrowing(conversion.Operand), + IsTriviallyNonThrowing(conversion.Operand), // '-x', '+x', '~x', '!x' outside a checked context. 'dynamic' and 'decimal' operands run code, and so does // the fifth built-in unary operator: '^x' constructs a System.Index, which rejects negative values. @@ -227,7 +229,7 @@ private static bool IsNonThrowingConversion(IConversionOperation operation) { Conversion conversion = operation.GetConversion(); if (!conversion.Exists || conversion.IsUserDefined || conversion.MethodSymbol is not null || - conversion.IsDynamic) + conversion.IsDynamic) { return false; } @@ -244,15 +246,15 @@ private static bool IsNonThrowingConversion(IConversionOperation operation) if (conversion.IsImplicit) { return conversion.IsNumeric - || conversion.IsReference - || conversion.IsPointer - || (conversion.IsNullable && IsNullableWrapping(source, target)); + || conversion.IsReference + || conversion.IsPointer + || (conversion.IsNullable && IsNullableWrapping(source, target)); } return !operation.IsChecked - && (conversion.IsNumeric || conversion.IsEnumeration || conversion.IsPointer) - && IsPrimitiveEnumOrPointer(source) - && IsPrimitiveEnumOrPointer(target); + && (conversion.IsNumeric || conversion.IsEnumeration || conversion.IsPointer) + && IsPrimitiveEnumOrPointer(source) + && IsPrimitiveEnumOrPointer(target); } // 'int -> int?', 'int -> long?', 'int? -> long?', 'Guid -> Guid?'. An implicit nullable conversion can also @@ -263,9 +265,9 @@ private static bool IsNullableWrapping(ITypeSymbol? source, ITypeSymbol? target) ITypeSymbol? from = UnwrapNullable(source); ITypeSymbol? to = UnwrapNullable(target); return from is not null - && to is not null - && (SymbolEqualityComparer.Default.Equals(from, to) || - (IsPrimitiveOrEnum(from) && IsPrimitiveOrEnum(to))); + && to is not null + && (SymbolEqualityComparer.Default.Equals(from, to) || + (IsPrimitiveOrEnum(from) && IsPrimitiveOrEnum(to))); } private static ITypeSymbol? UnwrapNullable(ITypeSymbol? type) @@ -285,12 +287,12 @@ private static bool IsPrimitiveEnumOrPointer(ITypeSymbol? type) private static bool IsPrimitiveOrEnum(ITypeSymbol? type) { return type is { TypeKind: TypeKind.Enum } - || type?.SpecialType is SpecialType.System_Boolean or SpecialType.System_Char - or SpecialType.System_SByte or SpecialType.System_Byte - or SpecialType.System_Int16 or SpecialType.System_UInt16 - or SpecialType.System_Int32 or SpecialType.System_UInt32 - or SpecialType.System_Int64 or SpecialType.System_UInt64 - or SpecialType.System_IntPtr or SpecialType.System_UIntPtr - or SpecialType.System_Single or SpecialType.System_Double; + || type?.SpecialType is SpecialType.System_Boolean or SpecialType.System_Char + or SpecialType.System_SByte or SpecialType.System_Byte + or SpecialType.System_Int16 or SpecialType.System_UInt16 + or SpecialType.System_Int32 or SpecialType.System_UInt32 + or SpecialType.System_Int64 or SpecialType.System_UInt64 + or SpecialType.System_IntPtr or SpecialType.System_UIntPtr + or SpecialType.System_Single or SpecialType.System_Double; } } diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs index 2d1dc3d5..6d60546a 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/PluginLifecycleAndOwnershipAnalyzer.cs @@ -77,8 +77,8 @@ private static void AnalyzeInvocation(OperationAnalysisContext context, PluginLi { IInvocationOperation invocation = (IInvocationOperation) context.Operation; if (symbols.RequiresPluginEnabled is not null && IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute) - && RequiresEnabled(invocation.TargetMethod, - symbols.RequiresPluginEnabled)) + && RequiresEnabled(invocation.TargetMethod, + symbols.RequiresPluginEnabled)) { context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.RequiresPluginEnabledTooEarly, invocation.Syntax.GetLocation(), DisplayName(invocation.TargetMethod))); @@ -102,7 +102,7 @@ private static void AnalyzePropertyReference(OperationAnalysisContext context, PluginLifecycleContractSymbols symbols) { if (symbols.RequiresPluginEnabled is null || - !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) + !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) { return; } @@ -120,14 +120,14 @@ private static void AnalyzePropertyReference(OperationAnalysisContext context, private static void AnalyzeObjectCreation(OperationAnalysisContext context, PluginLifecycleContractSymbols symbols) { if (symbols.RequiresPluginEnabled is null || - !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) + !IsTooEarly(context.ContainingSymbol, symbols.PluginAttribute)) { return; } IObjectCreationOperation creation = (IObjectCreationOperation) context.Operation; if (creation.Constructor is null || - !RequiresEnabled(creation.Constructor, symbols.RequiresPluginEnabled)) + !RequiresEnabled(creation.Constructor, symbols.RequiresPluginEnabled)) { return; } @@ -140,7 +140,7 @@ private static void AnalyzeMethod(SymbolAnalysisContext context, PluginLifecycle { IMethodSymbol method = (IMethodSymbol) context.Symbol; if (!method.IsAsync || !method.ReturnsVoid || - !IsPluginClass(method.ContainingType, symbols.PluginAttribute)) + !IsPluginClass(method.ContainingType, symbols.PluginAttribute)) { return; } @@ -183,8 +183,8 @@ private static bool IsLifecycleOverride(IMethodSymbol method, INamedTypeSymbol p } for (IMethodSymbol? overridden = method.OverriddenMethod; - overridden is not null; - overridden = overridden.OverriddenMethod) + overridden is not null; + overridden = overridden.OverriddenMethod) { if (SymbolEqualityComparer.Default.Equals(overridden.ContainingType, pluginBase)) { diff --git a/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs b/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs index 1f037769..f7e1589f 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Usage/UnmanagedCallersOnlyGuardAnalyzer.cs @@ -82,7 +82,7 @@ private static void AnalyzeMethodBody(OperationAnalysisContext context, INamedTy { IMethodBodyOperation body = (IMethodBodyOperation) context.Operation; if (context.ContainingSymbol is not IMethodSymbol method || - !IsUnmanagedCallersOnly(method, unmanagedCallersOnly)) + !IsUnmanagedCallersOnly(method, unmanagedCallersOnly)) { return; } diff --git a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs index 83b760c2..6c21f93d 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/WellKnown/SdkSymbolResolver.cs @@ -38,7 +38,7 @@ internal static class SdkSymbolResolver foreach (MetadataReference reference in compilation.References) { if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly - || !string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) + || !string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) { continue; } diff --git a/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs b/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs index 73db789e..8f738885 100644 --- a/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs +++ b/libs/CheatEngine.SDK.Abi/Native/BoundedDebugEventObservationBuffer.cs @@ -30,7 +30,7 @@ public BoundedDebugEventObservationBuffer(int capacity, DebugEventObservationOve { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); if (overflowPolicy is not DebugEventObservationOverflowPolicy.DropNewest - and not DebugEventObservationOverflowPolicy.DropOldest) + and not DebugEventObservationOverflowPolicy.DropOldest) { throw new ArgumentOutOfRangeException(nameof(overflowPolicy)); } diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs index 58b0e44d..2cb6520d 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs @@ -207,9 +207,9 @@ private static int Dispatch(void* nativeEvent) } if (!TryEnterActiveCallback( - out ClassicDebugEventDispatcher? dispatcher, - out DebugEventDecisionHandler handler, - out BoundedDebugEventObservationBuffer? observations)) + out ClassicDebugEventDispatcher? dispatcher, + out DebugEventDecisionHandler handler, + out BoundedDebugEventObservationBuffer? observations)) { return 0; } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs index ca6eaa62..30036470 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListCalls.cs @@ -32,7 +32,7 @@ public static bool TryGetGlobal(LuaRef cache, ReadOnlySpan try { if (!LuaGlobalFunctions.TryPush(state, cache, global) || !state.TryCall(0, 1).IsOk || - !TMarshaller.TryRead(state, -1, out result)) + !TMarshaller.TryRead(state, -1, out result)) { return LuaCallSupport.Fail(state, top, out result); } diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs index 2a3f924c..72d3fc55 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs @@ -261,7 +261,7 @@ private static MemoryRecordMutationOutcome ValidateParentChain(LuaState state, M { HashSet seen = new(); MemoryRecord current = proposedParent; - for (int hops = 0;; hops++) + for (int hops = 0; ; hops++) { LuaStatus status = current.Handle.TryGetProperty(state, "ID"u8); if (!status.IsOk) diff --git a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs index c2eeeeb9..c5df689b 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/AllocatedRegion.cs @@ -221,7 +221,7 @@ private TargetMemoryOperationOutcome ReleaseTakenWithOutcome() private void ThrowForReleaseOutcome(TargetMemoryOperationOutcome outcome) { if (outcome.Kind is TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable or - TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) { throw new EngineTargetIdentityException("TargetMemoryDeallocate", LastReleaseOutcome.TargetCheck.GetValueOrDefault()); diff --git a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs index a4453288..7d4be50d 100644 --- a/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs +++ b/libs/CheatEngine.SDK.Engine/Allocation/TargetMemoryAllocator.cs @@ -258,7 +258,7 @@ private static void ThrowForAllocationOutcome(TargetMemoryOperationOutcome outco } if (outcome.Kind is TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable or - TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) + TargetMemoryOperationOutcomeKind.TargetIdentityMismatch) { throw new EngineTargetIdentityException("TargetMemoryAllocate", GetUnavailableTargetCheck()); } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs index 5ea03595..a16638f1 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionDisassembler.cs @@ -120,9 +120,9 @@ public static InstructionOperationStatus TryDisassemble(InstructionTargetProfile } if (!state.TryReadUtf8(-4, out ReadOnlySpan addressUtf8) || - !state.TryReadUtf8(-3, out ReadOnlySpan bytesUtf8) || - !state.TryReadUtf8(-2, out ReadOnlySpan opcodeUtf8) || - !state.TryReadUtf8(-1, out ReadOnlySpan extraUtf8)) + !state.TryReadUtf8(-3, out ReadOnlySpan bytesUtf8) || + !state.TryReadUtf8(-2, out ReadOnlySpan opcodeUtf8) || + !state.TryReadUtf8(-1, out ReadOnlySpan extraUtf8)) { return InstructionOperationStatus.InvalidResult; } diff --git a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs index c371053a..54a8d1c4 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/InstructionProfiles.cs @@ -127,7 +127,7 @@ private static InstructionOperationStatus TryGetCurrentTarget(LuaState state, ou } if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long value) || - value is < 0 or > int.MaxValue) + value is < 0 or > int.MaxValue) { return InstructionOperationStatus.InvalidResult; } diff --git a/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs b/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs index b793f0af..b130deb9 100644 --- a/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs +++ b/libs/CheatEngine.SDK.Engine/Enums/CEEnumNames.cs @@ -312,8 +312,8 @@ public static ReadOnlySpan ToCEName(this MemoryProtection value) public static bool TryParseCEName(ReadOnlySpan ceName, out MemoryProtection value) { for (MemoryProtection candidate = MemoryProtection.ReadOnly; - candidate <= MemoryProtection.ExecuteWriteCopy; - candidate = (MemoryProtection) ((uint) candidate << 1)) + candidate <= MemoryProtection.ExecuteWriteCopy; + candidate = (MemoryProtection) ((uint) candidate << 1)) { if (ceName.SequenceEqual(candidate.ToCEName())) { diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs index f504b52b..0493514c 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineGlobalUnavailableException.cs @@ -49,6 +49,6 @@ public string Operation private static string CreateDefaultMessage(string operation) { return "The required binding global for Cheat Engine operation '" + - RequireText(operation, nameof(operation)) + "' is unavailable."; + RequireText(operation, nameof(operation)) + "' is unavailable."; } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs index 77bb10a3..0ff4c182 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineLuaException.cs @@ -75,6 +75,6 @@ private static string CreateDefaultMessage(string operation, LuaStatus status) } return "The protected Lua operation '" + RequireText(operation, nameof(operation)) + "' failed with status " + - status + "."; + status + "."; } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs index 92f6ed1d..61a54231 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineMarshallingException.cs @@ -87,8 +87,8 @@ private static string CreateDefaultMessage(string operation, EngineMarshallingDi string directionText = ValidateDirection(direction) == EngineMarshallingDirection.Argument ? "argument" : "result"; return "The " + directionText + " of Cheat Engine operation '" + - RequireText(operation, nameof(operation)) + "' could not be marshalled: expected " + - RequireText(expected, nameof(expected)) + ", observed " + RequireText(actual, nameof(actual)) + "."; + RequireText(operation, nameof(operation)) + "' could not be marshalled: expected " + + RequireText(expected, nameof(expected)) + ", observed " + RequireText(actual, nameof(actual)) + "."; } private static EngineMarshallingDirection ValidateDirection(EngineMarshallingDirection direction) diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs index a95af947..f29f792e 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineResourceHandoffException.cs @@ -48,7 +48,7 @@ public TargetReleaseOutcome CleanupOutcome private static string CreateMessage(string operation, TargetReleaseOutcome cleanupOutcome) { return "The Engine operation '" + RequireText(operation, nameof(operation)) + - "' completed before its ownership could be published; compensation ended as " + - cleanupOutcome.Status + "."; + "' completed before its ownership could be published; compensation ended as " + + cleanupOutcome.Status + "."; } } diff --git a/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs b/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs index 40e0063f..0791a540 100644 --- a/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs +++ b/libs/CheatEngine.SDK.Engine/Errors/EngineTargetIdentityException.cs @@ -36,6 +36,6 @@ public TargetIdentityCheck Check private static string CreateMessage(string operation, TargetIdentityCheck check) { return "The Engine operation '" + RequireText(operation, nameof(operation)) + - "' was refused because its original target is not verified as current (" + check.Kind + ")."; + "' was refused because its original target is not verified as current (" + check.Kind + ")."; } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs b/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs index e081e23c..73b5aedf 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs @@ -512,7 +512,7 @@ private static InspectionStatus ReadModuleCollection(LuaState state, int tableIn for (int index = 0; index < sequenceCount; index++) { if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadModuleInfo(state, -1, out snapshot[index])) + !TryReadModuleInfo(state, -1, out snapshot[index])) { return InspectionStatus.InvalidResult; } @@ -554,7 +554,7 @@ private static InspectionStatus ReadSectionCollection(LuaState state, int tableI for (int index = 0; index < sequenceCount; index++) { if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadModuleSectionInfo(state, -1, out snapshot[index])) + !TryReadModuleSectionInfo(state, -1, out snapshot[index])) { return InspectionStatus.InvalidResult; } @@ -596,7 +596,7 @@ private static InspectionStatus ReadMemoryRegionCollection(LuaState state, int t for (int index = 0; index < sequenceCount; index++) { if (state.RawGetSequenceItem(tableIndex, index) != LuaType.Table || - !TryReadMemoryRegionInfo(state, -1, out snapshot[index])) + !TryReadMemoryRegionInfo(state, -1, out snapshot[index])) { return InspectionStatus.InvalidResult; } @@ -627,10 +627,10 @@ private static bool TryReadModuleInfo(LuaState state, int tableIndex, out Module { module = default; if (!TryReadRequiredStringField(state, tableIndex, "Name"u8, out string? name) || - !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || - !TryReadOptionalMemorySizeField(state, tableIndex, "Size"u8, out MemorySize? size) || - !TryReadBooleanField(state, tableIndex, "Is64Bit"u8, out bool is64Bit) || - !TryReadRequiredStringField(state, tableIndex, "PathToFile"u8, out string? pathToFile)) + !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || + !TryReadOptionalMemorySizeField(state, tableIndex, "Size"u8, out MemorySize? size) || + !TryReadBooleanField(state, tableIndex, "Is64Bit"u8, out bool is64Bit) || + !TryReadRequiredStringField(state, tableIndex, "PathToFile"u8, out string? pathToFile)) { return false; } @@ -643,9 +643,9 @@ private static bool TryReadModuleSectionInfo(LuaState state, int tableIndex, out { section = default; if (!TryReadRequiredStringField(state, tableIndex, "Name"u8, out string? name) || - !TryReadMemorySizeField(state, tableIndex, "Size"u8, out MemorySize size) || - !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || - !TryReadUInt64Field(state, tableIndex, "FileAddress"u8, out ulong fileAddress)) + !TryReadMemorySizeField(state, tableIndex, "Size"u8, out MemorySize size) || + !TryReadAddressField(state, tableIndex, "Address"u8, out Address address) || + !TryReadUInt64Field(state, tableIndex, "FileAddress"u8, out ulong fileAddress)) { return false; } @@ -658,9 +658,9 @@ private static bool TryReadSymbolInfo(LuaState state, int tableIndex, out Symbol { symbol = default; if (!TryReadRequiredStringField(state, tableIndex, "modulename"u8, out string? moduleName) || - !TryReadRequiredStringField(state, tableIndex, "searchkey"u8, out string? searchKey) || - !TryReadAddressField(state, tableIndex, "address"u8, out Address address) || - !TryReadMemorySizeField(state, tableIndex, "symbolsize"u8, out MemorySize size)) + !TryReadRequiredStringField(state, tableIndex, "searchkey"u8, out string? searchKey) || + !TryReadAddressField(state, tableIndex, "address"u8, out Address address) || + !TryReadMemorySizeField(state, tableIndex, "symbolsize"u8, out MemorySize size)) { return false; } @@ -673,14 +673,14 @@ private static bool TryReadMemoryRegionInfo(LuaState state, int tableIndex, out { region = default; if (!TryReadAddressField(state, tableIndex, "BaseAddress"u8, out Address baseAddress) || - !TryReadAddressField(state, tableIndex, "AllocationBase"u8, out Address allocationBase) || - !TryReadProtectionField(state, tableIndex, "AllocationProtect"u8, - out MemoryProtection allocationProtection) || - !TryReadMemorySizeField(state, tableIndex, "RegionSize"u8, out MemorySize size) || - !TryReadUInt32Field(state, tableIndex, "State"u8, out uint stateValue) || - !TryReadProtectionField(state, tableIndex, "Protect"u8, out MemoryProtection protection) || - !TryReadUInt32Field(state, tableIndex, "Type"u8, out uint typeValue) || - !TryReadOptionalStringField(state, tableIndex, "Extra"u8, out string? extra)) + !TryReadAddressField(state, tableIndex, "AllocationBase"u8, out Address allocationBase) || + !TryReadProtectionField(state, tableIndex, "AllocationProtect"u8, + out MemoryProtection allocationProtection) || + !TryReadMemorySizeField(state, tableIndex, "RegionSize"u8, out MemorySize size) || + !TryReadUInt32Field(state, tableIndex, "State"u8, out uint stateValue) || + !TryReadProtectionField(state, tableIndex, "Protect"u8, out MemoryProtection protection) || + !TryReadUInt32Field(state, tableIndex, "Type"u8, out uint typeValue) || + !TryReadOptionalStringField(state, tableIndex, "Extra"u8, out string? extra)) { return false; } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs index f138f20b..2dbf2542 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs @@ -189,7 +189,7 @@ internal static SymbolRegistrationReleaseOutcome ReleaseOwned(SymbolRegistration } if (!SOwnedRegistrations.TryGetValue(lease.Name, out SymbolRegistrationLease? current) || - !ReferenceEquals(current, lease)) + !ReferenceEquals(current, lease)) { lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.Superseded); return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Superseded, @@ -208,7 +208,7 @@ private static SymbolRegistrationReleaseOutcome ReleaseCurrentLease(SymbolRegist status = UnregisterCore(lease.Name); } catch (InvalidOperationException) when (!LuaRuntime.IsAttached || - lease.Identity != LuaRuntime.CurrentStateIdentity) + lease.Identity != LuaRuntime.CurrentStateIdentity) { return MarkStaleRuntime(lease); } @@ -292,7 +292,7 @@ private static SymbolRegistrationReleaseOutcome CompensateFailedPublication(Symb status = UnregisterCore(name); } catch (InvalidOperationException) when (!LuaRuntime.IsAttached || - identity != LuaRuntime.CurrentStateIdentity) + identity != LuaRuntime.CurrentStateIdentity) { return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, LuaOperationStatus.Success); @@ -333,7 +333,7 @@ private static void SupersedeCurrentLease(SymbolName name) private static void RemoveCurrentLease(SymbolRegistrationLease lease) { if (SOwnedRegistrations.TryGetValue(lease.Name, out SymbolRegistrationLease? current) && - ReferenceEquals(current, lease)) + ReferenceEquals(current, lease)) { SOwnedRegistrations.Remove(lease.Name); } diff --git a/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs b/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs index aec09e51..ef2a0e2b 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/HostMemory.cs @@ -70,8 +70,8 @@ public static bool TryReadInt8(HostAddress address, out sbyte value, out MemoryA public static bool TryReadUInt16(HostAddress address, out ushort value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallIntegerLocal"u8, address.ToInt64(), false, - true, - out long raw, out failure) || raw < 0 || raw > ushort.MaxValue) + true, + out long raw, out failure) || raw < 0 || raw > ushort.MaxValue) { value = default; if (failure == MemoryAccessFailure.None) @@ -90,8 +90,8 @@ public static bool TryReadUInt16(HostAddress address, out ushort value, out Memo public static bool TryReadInt16(HostAddress address, out short value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallIntegerLocal"u8, address.ToInt64(), true, - true, - out long raw, out failure) || raw < short.MinValue || raw > short.MaxValue) + true, + out long raw, out failure) || raw < short.MinValue || raw > short.MaxValue) { value = default; if (failure == MemoryAccessFailure.None) @@ -110,8 +110,8 @@ public static bool TryReadInt16(HostAddress address, out short value, out Memory public static bool TryReadUInt32(HostAddress address, out uint value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadInteger, "readIntegerLocal"u8, address.ToInt64(), false, - true, out long raw, - out failure) || raw < 0 || (ulong) raw > uint.MaxValue) + true, out long raw, + out failure) || raw < 0 || (ulong) raw > uint.MaxValue) { value = default; if (failure == MemoryAccessFailure.None) @@ -130,8 +130,8 @@ public static bool TryReadUInt32(HostAddress address, out uint value, out Memory public static bool TryReadInt32(HostAddress address, out int value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadInteger, "readIntegerLocal"u8, address.ToInt64(), true, - true, out long raw, - out failure) || raw < int.MinValue || raw > int.MaxValue) + true, out long raw, + out failure) || raw < int.MinValue || raw > int.MaxValue) { value = default; if (failure == MemoryAccessFailure.None) @@ -150,8 +150,8 @@ public static bool TryReadInt32(HostAddress address, out int value, out MemoryAc public static bool TryReadUInt64(HostAddress address, out ulong value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadQword, "readQwordLocal"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; @@ -173,8 +173,8 @@ public static bool TryReadInt64(HostAddress address, out long value, out MemoryA public static bool TryReadPointer(HostAddress address, out HostAddress value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadPointer, "readPointerLocal"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; diff --git a/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs b/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs index 6ac46977..342fe523 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/MemoryLua.cs @@ -288,7 +288,7 @@ private static bool TryCopyCompleteBytes(LuaState state, int table, Span d { LuaType type = state.RawGetIndex(table, index + 1L); bool valid = type == LuaType.Number && state.TryReadInteger(-1, out long value) - && (ulong) value <= byte.MaxValue; + && (ulong) value <= byte.MaxValue; state.Pop(1); if (!valid) { @@ -319,7 +319,7 @@ private static bool TryCopyPartialBytes(LuaState state, int table, Span de LuaType type = state.RawGetIndex(table, index + 1L); long value = default; bool valid = type == LuaType.Number && state.TryReadInteger(-1, out value) - && (ulong) value <= byte.MaxValue; + && (ulong) value <= byte.MaxValue; state.Pop(1); if (!valid) { diff --git a/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs b/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs index 41ab0122..5f1a8a3f 100644 --- a/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs +++ b/libs/CheatEngine.SDK.Engine/Memory/TargetMemory.cs @@ -55,8 +55,8 @@ public static class TargetMemory public static bool TryReadUInt8(Address address, out byte value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadByte, "readByte"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; @@ -82,9 +82,9 @@ public static bool TryReadInt8(Address address, out sbyte value, out MemoryAcces public static bool TryReadUInt16(Address address, out ushort value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallInteger"u8, address.ToInt64(), false, - true, - out long raw, - out failure)) + true, + out long raw, + out failure)) { value = default; return false; @@ -97,9 +97,9 @@ public static bool TryReadUInt16(Address address, out ushort value, out MemoryAc public static bool TryReadInt16(Address address, out short value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadSmallInteger, "readSmallInteger"u8, address.ToInt64(), true, - true, - out long raw, - out failure)) + true, + out long raw, + out failure)) { value = default; return false; @@ -112,8 +112,8 @@ public static bool TryReadInt16(Address address, out short value, out MemoryAcce public static bool TryReadUInt32(Address address, out uint value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadInteger, "readInteger"u8, address.ToInt64(), false, - true, out long raw, - out failure)) + true, out long raw, + out failure)) { value = default; return false; @@ -126,8 +126,8 @@ public static bool TryReadUInt32(Address address, out uint value, out MemoryAcce public static bool TryReadInt32(Address address, out int value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadInteger, "readInteger"u8, address.ToInt64(), true, - true, out long raw, - out failure)) + true, out long raw, + out failure)) { value = default; return false; @@ -140,8 +140,8 @@ public static bool TryReadInt32(Address address, out int value, out MemoryAccess public static bool TryReadUInt64(Address address, out ulong value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadQword, "readQword"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; @@ -162,8 +162,8 @@ public static bool TryReadInt64(Address address, out long value, out MemoryAcces public static bool TryReadPointer(Address address, out Address value, out MemoryAccessFailure failure) { if (!MemoryLua.TryReadInteger(SReadPointer, "readPointer"u8, address.ToInt64(), false, - false, out long raw, - out failure)) + false, out long raw, + out failure)) { value = default; return false; diff --git a/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs b/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs index ae9d4cdd..bfef42bf 100644 --- a/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs +++ b/libs/CheatEngine.SDK.Engine/Objects/StringLists.cs @@ -31,8 +31,8 @@ public static bool TryCreate([NotNullWhen(true)] out Owned? list) LuaState state = operation.State; using LuaFrame frame = new(state); if (!LuaGlobalFunctions.TryPush(state, SCreateStringList, "createStringlist"u8) || - !state.TryCall(0, 1).IsOk || - !CEObject.TryRead(state, -1, out CEObject handle)) + !state.TryCall(0, 1).IsOk || + !CEObject.TryRead(state, -1, out CEObject handle)) { list = null; return false; diff --git a/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs b/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs index 8bda10ca..f6173e61 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/RuntimeHostOperations.cs @@ -115,7 +115,7 @@ internal static LuaOperationStatus TryCallInteger(LuaState state, LuaRef cache, } if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long raw) || - raw is < int.MinValue or > int.MaxValue) + raw is < int.MinValue or > int.MaxValue) { value = default; return state.IsNil(-1) ? LuaOperationStatus.NilResult : LuaOperationStatus.InvalidResult; @@ -146,7 +146,7 @@ private static LuaOperationStatus TryGetCheatEngineVersion(LuaState state, out d } if (state.TypeOf(-1) != LuaType.Number || !state.TryReadNumber(-1, out version) || - !double.IsFinite(version) || version < 0) + !double.IsFinite(version) || version < 0) { version = default; return state.IsNil(-1) ? LuaOperationStatus.NilResult : LuaOperationStatus.InvalidResult; diff --git a/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs b/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs index cd1559ea..a33a5561 100644 --- a/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs +++ b/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs @@ -65,7 +65,7 @@ public static ProcessOperationStatus SelectAndObserve(TargetProcessId processId, status = TryGetOpenedProcessId(state, out TargetProcessId? observedProcessId); if (status.Kind == ProcessOperationStatusKind.TargetNotAttached || - (status.IsSuccess && observedProcessId != processId)) + (status.IsSuccess && observedProcessId != processId)) { observation = default; return ProcessOperationStatus.SelectionNotConfirmed; @@ -139,7 +139,7 @@ private static ProcessOperationStatus TryGetOpenedProcessId(LuaState state, out } if (state.TypeOf(-1) != LuaType.Number || !state.TryReadInteger(-1, out long value) || - value is < 0 or > int.MaxValue) + value is < 0 or > int.MaxValue) { processId = default; return ProcessOperationStatus.InvalidResult; diff --git a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs index f98e87d3..7697a830 100644 --- a/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs +++ b/libs/CheatEngine.SDK.Engine/Runtime/CheatEngineVersion.cs @@ -109,9 +109,9 @@ public override int GetHashCode() public override string ToString() { return Major.ToString(CultureInfo.InvariantCulture) + "." + - Minor.ToString(CultureInfo.InvariantCulture) + "." + - Release.ToString(CultureInfo.InvariantCulture) + "." + - Build.ToString(CultureInfo.InvariantCulture); + Minor.ToString(CultureInfo.InvariantCulture) + "." + + Release.ToString(CultureInfo.InvariantCulture) + "." + + Build.ToString(CultureInfo.InvariantCulture); } /// Tests two versions for equality. diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs index 90dbe341..db1f5304 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Aob/AobScanOptions.cs @@ -94,8 +94,8 @@ public string? AlignmentParameter public bool Equals(AobScanOptions other) { return string.Equals(ProtectionFlags, other.ProtectionFlags, StringComparison.Ordinal) && - AlignmentMethod == other.AlignmentMethod && - string.Equals(AlignmentParameter, other.AlignmentParameter, StringComparison.Ordinal); + AlignmentMethod == other.AlignmentMethod && + string.Equals(AlignmentParameter, other.AlignmentParameter, StringComparison.Ordinal); } /// diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs index 7ae6d686..620c7c28 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs @@ -843,7 +843,7 @@ private MemoryScanReleaseOutcome CompleteRelease(TargetReleaseOutcome foundListO private TargetReleaseOutcome CreateRefusedReleaseOutcome(MemoryScanMaterializationStatus context) { if (context is MemoryScanMaterializationStatus.TargetIdentityUnavailable or - MemoryScanMaterializationStatus.TargetIdentityMismatch && LastTargetCheck.HasValue) + MemoryScanMaterializationStatus.TargetIdentityMismatch && LastTargetCheck.HasValue) { return TargetReleaseOutcome.Refused(LastTargetCheck.GetValueOrDefault()); } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs index 3502fd93..642212d6 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs @@ -157,8 +157,8 @@ private static MemoryScanCreationStatus TryCreateDetailedCore([NotNullWhen(true) bool foundListRollbackFailed = !TryRollback(state, foundList, foundListHandle); bool scannerRollbackFailed = !TryRollback(state, scanner, scannerHandle); if ((foundListRollbackFailed || scannerRollbackFailed) - && session is null - && status != MemoryScanCreationStatus.Success) + && session is null + && status != MemoryScanCreationStatus.Success) { status = MemoryScanCreationStatus.RollbackUnconfirmed; } diff --git a/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs b/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs index 23af4d95..bd2c0ba9 100644 --- a/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs +++ b/libs/CheatEngine.SDK.Engine/Targets/TargetSelection.cs @@ -131,7 +131,7 @@ private static bool TryObserveIncarnation(int processId, out TargetProcessIncarn return true; } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or Win32Exception - or NotSupportedException or UnauthorizedAccessException) + or NotSupportedException or UnauthorizedAccessException) { incarnation = default; return false; diff --git a/libs/CheatEngine.SDK.Engine/Values/Address.cs b/libs/CheatEngine.SDK.Engine/Values/Address.cs index 5c01ee30..c71ddf7e 100644 --- a/libs/CheatEngine.SDK.Engine/Values/Address.cs +++ b/libs/CheatEngine.SDK.Engine/Values/Address.cs @@ -574,7 +574,7 @@ private static int HexDigitValue(byte b) private static void ThrowFormat(ReadOnlySpan text) { throw new FormatException("'" + text.ToString() + - "' is not a hexadecimal address (digits with an optional 0x prefix)."); + "' is not a hexadecimal address (digits with an optional 0x prefix)."); } [DoesNotReturn] diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs index 704cc110..a7eee0de 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.Lifecycle.cs @@ -139,7 +139,7 @@ private static bool TryEnterLifecycleCallback(string callback) if (SGate.IsHeldByCurrentThread) { HostLog.Error(callback + - ": re-entered from plugin code while OnEnable or OnDisable is running on this thread; the call is refused and the outer transition decides the state."); + ": re-entered from plugin code while OnEnable or OnDisable is running on this thread; the call is refused and the outer transition decides the state."); return false; } @@ -149,7 +149,7 @@ private static bool TryEnterLifecycleCallback(string callback) } HostLog.Error(callback + - ": another lifecycle transition is already running; concurrent callbacks fail immediately and do not wait for plugin code."); + ": another lifecycle transition is already running; concurrent callbacks fail immediately and do not wait for plugin code."); return false; } @@ -220,7 +220,7 @@ private static LifecycleStart TryStartEnable(out PluginDescriptor? descriptor) if (Phase is not PluginHostLifecyclePhase.Registered) { HostLog.Error("EnablePlugin: the plugin lifecycle is in " + Phase + - "; enable is valid only from Registered."); + "; enable is valid only from Registered."); return LifecycleStart.Refused; } @@ -528,17 +528,17 @@ private static LifecycleStart SelectDisableStart( if (context is null) { HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; disable is valid only from Enabled or incomplete failed-enable cleanup."); + "; disable is valid only from Enabled or incomplete failed-enable cleanup."); return LifecycleStart.Refused; } if (Phase is PluginHostLifecyclePhase.Disabling) { if (Volatile.Read(ref s_incompleteEnableCleanup) == 0 - || Volatile.Read(ref s_incompleteEnableCleanupActive) != 0) + || Volatile.Read(ref s_incompleteEnableCleanupActive) != 0) { HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; a disable transition is already completing."); + "; a disable transition is already completing."); return LifecycleStart.Refused; } @@ -548,7 +548,7 @@ private static LifecycleStart SelectDisableStart( if (Phase is not PluginHostLifecyclePhase.Enabled) { HostLog.Error("DisablePlugin: the plugin lifecycle is in " + Phase + - "; disable is valid only from Enabled or incomplete failed-enable cleanup."); + "; disable is valid only from Enabled or incomplete failed-enable cleanup."); return LifecycleStart.Refused; } diff --git a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs index 8f9ac2f0..0ef5f036 100644 --- a/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs +++ b/libs/CheatEngine.SDK.Hosting/Bootstrap/PluginHost.cs @@ -118,8 +118,8 @@ internal static MainThreadWorkAdmission AdmitMainThreadWork(PluginContext contex lock (SAdmissionGate) { if (Phase is not PluginHostLifecyclePhase.Enabled || - !ReferenceEquals(context, Volatile.Read(ref s_context)) || - Volatile.Read(ref s_acceptingMainThreadWork) == 0) + !ReferenceEquals(context, Volatile.Read(ref s_context)) || + Volatile.Read(ref s_acceptingMainThreadWork) == 0) { throw new InvalidOperationException( "The plugin is stopping or disabled and no longer accepts new main-thread dispatch work."); diff --git a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs index a230f642..9e0f8164 100644 --- a/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs +++ b/libs/CheatEngine.SDK.Hosting/Threading/MainThreadDispatcher.cs @@ -102,7 +102,7 @@ internal static void Dispatch(MainThreadWorkItem item) if (!status.IsOk) { throw new InvalidOperationException("The host's 'synchronize' global could not be read: " + - LuaError.FromStack(l, status).Message); + LuaError.FromStack(l, status).Message); } if (!l.IsFunction(-1)) @@ -116,7 +116,7 @@ internal static void Dispatch(MainThreadWorkItem item) if (!status.IsOk) { throw new InvalidOperationException("The dispatch callback could not be created: " + - LuaError.FromStack(l, status).Message); + LuaError.FromStack(l, status).Message); } using (callback) @@ -132,7 +132,7 @@ internal static void Dispatch(MainThreadWorkItem item) if (!status.IsOk) { throw new InvalidOperationException("The host's 'synchronize' call failed: " + - LuaError.FromStack(l, status).Message); + LuaError.FromStack(l, status).Message); } } diff --git a/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs b/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs index 0a23d4bc..9ecce11a 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/GlobalUsings.cs @@ -4,20 +4,19 @@ // // Scalars (luaconf.h of a default 64-bit build: LUA_INT_LONGLONG + LUA_REAL_DOUBLE, which is what Cheat Engine ships). -global using lua_Integer = long; -global using lua_KContext = nint; -global using lua_Number = double; -global using size_t = nuint; - // Function-pointer typedefs. Lua is cdecl; the convention is a no-op on x64 but matters on x86, so it is spelled out. global using unsafe lua_Alloc = delegate* unmanaged[Cdecl]; global using unsafe lua_CFunction = delegate* unmanaged[Cdecl]; global using unsafe lua_Hook = delegate* unmanaged[Cdecl]< CheatEngine.SDK.Lua.Interop.Types.lua_State*, CheatEngine.SDK.Lua.Interop.Types.lua_Debug*, void>; +global using lua_Integer = long; +global using lua_KContext = nint; global using unsafe lua_KFunction = delegate* unmanaged[Cdecl]; +global using lua_Number = double; global using unsafe lua_Reader = delegate* unmanaged[Cdecl]; global using unsafe lua_Writer = delegate* unmanaged[Cdecl]; +global using size_t = nuint; diff --git a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs index ec24718c..918b1510 100644 --- a/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs +++ b/libs/CheatEngine.SDK.Lua.Interop/Protected/LuaBridgeContract.cs @@ -38,15 +38,15 @@ internal struct LuaBridgeContract internal readonly bool IsCompatible() { return Magic == ExpectedMagic && - ContractSize == (uint) Unsafe.SizeOf() && - AbiMajor == ExpectedMajor && - AbiMinor >= MinimumMinor && - PointerSize == (byte) lua_KContext.Size && - LuaIntegerSize == (byte) Unsafe.SizeOf() && - SizeTSize == (byte) Unsafe.SizeOf() && - ExportTableSize == (uint) Unsafe.SizeOf() && - Reserved == 0 && - (SupportedOperations & LuaProtectedOperationContract.RequiredBitmap) == - LuaProtectedOperationContract.RequiredBitmap; + ContractSize == (uint) Unsafe.SizeOf() && + AbiMajor == ExpectedMajor && + AbiMinor >= MinimumMinor && + PointerSize == (byte) lua_KContext.Size && + LuaIntegerSize == (byte) Unsafe.SizeOf() && + SizeTSize == (byte) Unsafe.SizeOf() && + ExportTableSize == (uint) Unsafe.SizeOf() && + Reserved == 0 && + (SupportedOperations & LuaProtectedOperationContract.RequiredBitmap) == + LuaProtectedOperationContract.RequiredBitmap; } } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs index ba33fe99..de5ef1ae 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscription.cs @@ -226,9 +226,9 @@ private bool TryEnterCallback(out Action callback) { callback = null!; if (!_acceptCallbacks - || !LuaRuntime.IsAttached - || LuaRuntime.CurrentStateIdentity != Identity - || _callback is null) + || !LuaRuntime.IsAttached + || LuaRuntime.CurrentStateIdentity != Identity + || _callback is null) { return false; } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs index 98513af0..f0c35d22 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaHostSubscriptionRegistry.cs @@ -47,8 +47,8 @@ internal static bool TryAdd(LuaHostSubscription subscription) lock (Gate) { if (!s_acceptRegistrations - || !LuaRuntime.IsAttached - || LuaRuntime.CurrentStateIdentity != subscription.Identity) + || !LuaRuntime.IsAttached + || LuaRuntime.CurrentStateIdentity != subscription.Identity) { return false; } diff --git a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs index fbdad622..e0528cf3 100644 --- a/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs +++ b/libs/CheatEngine.SDK.Lua/CompilerServices/LuaCallSupport.cs @@ -148,6 +148,6 @@ public static void ThrowUnexpectedResult(LuaState state, int top, int index, str string typeName = Encoding.UTF8.GetString(state.TypeName(index)); state.SetTop(top); throw new LuaException("The Lua global '" + globalName + "' returned a " + typeName + " value, not " + - expected + "."); + expected + "."); } } diff --git a/libs/CheatEngine.SDK.Lua/References/LuaRef.cs b/libs/CheatEngine.SDK.Lua/References/LuaRef.cs index c873039e..e8b04e94 100644 --- a/libs/CheatEngine.SDK.Lua/References/LuaRef.cs +++ b/libs/CheatEngine.SDK.Lua/References/LuaRef.cs @@ -162,8 +162,8 @@ public void Release(LuaState state) { LuaRefBinding? binding = Interlocked.Exchange(ref _binding, null); if (binding is not null && binding.Reference != NoReference && - binding.Identity == LuaRuntime.CurrentStateIdentity && - !state.IsNull) + binding.Identity == LuaRuntime.CurrentStateIdentity && + !state.IsNull) { LuaReferences.Release(state, binding.Reference); } diff --git a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs index 03750711..8ee670fb 100644 --- a/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs +++ b/libs/CheatEngine.SDK.Lua/Registration/LuaRegistrationSet.cs @@ -35,7 +35,7 @@ public static LuaRegistrationResult Register(LuaState state, ReadOnlySpan diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs index 3de7baa4..3c65ff03 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs @@ -604,8 +604,8 @@ internal static void CloseHostSubscriptionAdmissionAndDrain() internal static bool IsGeneratedFunctionRegistrationCurrent(int attachEpoch, int stateGeneration) { return Read(ref s_services) is not null - && Read(ref s_identity) == PackIdentity(attachEpoch, stateGeneration) - && IsOperationAdmissionOpen(); + && Read(ref s_identity) == PackIdentity(attachEpoch, stateGeneration) + && IsOperationAdmissionOpen(); } /// diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs index 8728ead4..1ccc62b1 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Tables.cs @@ -86,7 +86,7 @@ public bool TryRawSet(int tableIndex) { int keyType = lua_type(Pointer, -2); if (keyType == LUA_TNIL || (keyType == LUA_TNUMBER && lua_isinteger(Pointer, -2) == 0 && - double.IsNaN(lua_tonumberx(Pointer, -2, null)))) + double.IsNaN(lua_tonumberx(Pointer, -2, null)))) { lua_settop(Pointer, -3); return false; diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs index f158860c..fe073a90 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Emit/EngineApiFileEmitter.cs @@ -179,7 +179,8 @@ private static void EmitAddressTypedWrapper(SourceWriter writer, SpecCallModel e LuaGlobalCallModel call = entry.Call; LuaGlobalCallModel core = call with { - Modifiers = "private static", MethodName = CoreMethodName(call.MethodName) + Modifiers = "private static", + MethodName = CoreMethodName(call.MethodName) }; writer.Write("// Raw core of '"); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs index f6e7986f..777ac885 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Model/SpecFiles.cs @@ -56,7 +56,10 @@ private static void AssignUniqueHintNames(List specs) } } - specs[i] = specs[i] with { HintName = hintName }; + specs[i] = specs[i] with + { + HintName = hintName + }; } } @@ -95,7 +98,10 @@ private static void SuppressCrossFileConflicts(List specs) foreach (int index in indices) { - specs[index] = specs[index] with { IsSuppressed = true }; + specs[index] = specs[index] with + { + IsSuppressed = true + }; } } } @@ -116,12 +122,14 @@ private static void AppendMemberConflictIssues(List specs, List> owners = new(StringComparer.Ordinal); foreach (int index in indices) - foreach (SpecCallModel call in specs[index].Calls) { - AddOwner(owners, call.Call.MethodName, (index, call.MethodLine, call.MethodColumn)); - if (UsesAddressFacade(call.Call)) + foreach (SpecCallModel call in specs[index].Calls) { - AddOwner(owners, CoreMethodName(call.Call.MethodName), (index, call.MethodLine, call.MethodColumn)); + AddOwner(owners, call.Call.MethodName, (index, call.MethodLine, call.MethodColumn)); + if (UsesAddressFacade(call.Call)) + { + AddOwner(owners, CoreMethodName(call.Call.MethodName), (index, call.MethodLine, call.MethodColumn)); + } } } @@ -194,7 +202,10 @@ private static void AddConflict(ref SpecFileModel spec, int line, int column, st { List issues = [.. spec.Issues]; issues.Add(new SpecIssue(line, message, column, SpecIssueKind.Conflict)); - spec = spec with { Issues = new EquatableArray([.. issues]) }; + spec = spec with + { + Issues = new EquatableArray([.. issues]) + }; } private static string QualifiedTypeName(SpecFileModel spec) diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs index c0489457..2f26fb33 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EngineApi/Parsing/SpecFileParser.cs @@ -199,13 +199,13 @@ private static bool ParseHeader(List blocks, List issues, out } if (!ReadHeaderFields( - header, - issues, - out string? namespaceValue, - out string? typeValue, - out typeLine, - out typeColumn, - out contract)) + header, + issues, + out string? namespaceValue, + out string? typeValue, + out typeLine, + out typeColumn, + out contract)) { return false; } @@ -356,10 +356,10 @@ private static bool TryCreateCe77Contract(HeaderFields fields, Block header, Lis { contract = null; if (!TryRequireContractField(fields.Provenance, "provenance", header, issues, out SpecField provenance) - || !TryRequireContractField(fields.MinimumCe, "minimum-ce", header, issues, out SpecField minimumCe) - || !TryRequireContractField(fields.Architecture, "architecture", header, issues, out SpecField architecture) - || !TryRequireContractField(fields.Thread, "thread", header, issues, out SpecField thread) - || !TryRequireContractField(fields.Ownership, "ownership", header, issues, out SpecField ownership)) + || !TryRequireContractField(fields.MinimumCe, "minimum-ce", header, issues, out SpecField minimumCe) + || !TryRequireContractField(fields.Architecture, "architecture", header, issues, out SpecField architecture) + || !TryRequireContractField(fields.Thread, "thread", header, issues, out SpecField thread) + || !TryRequireContractField(fields.Ownership, "ownership", header, issues, out SpecField ownership)) { return false; } @@ -493,11 +493,11 @@ private static bool IsValidProvenance(string value) string status = value[..colon]; return string.Equals(status, "ExactBinary", StringComparison.Ordinal) - || string.Equals(status, "ExactInstalledFile", StringComparison.Ordinal) - || string.Equals(status, "PinnedUpstream", StringComparison.Ordinal) - || string.Equals(status, "ObservedLive", StringComparison.Ordinal) - || string.Equals(status, "Inferred", StringComparison.Ordinal) - || string.Equals(status, "Unknown", StringComparison.Ordinal); + || string.Equals(status, "ExactInstalledFile", StringComparison.Ordinal) + || string.Equals(status, "PinnedUpstream", StringComparison.Ordinal) + || string.Equals(status, "ObservedLive", StringComparison.Ordinal) + || string.Equals(status, "Inferred", StringComparison.Ordinal) + || string.Equals(status, "Unknown", StringComparison.Ordinal); } private static bool IsFourPartVersion(string value) @@ -532,23 +532,23 @@ private static bool IsFourPartVersion(string value) private static bool IsThreadAffinity(string value) { return string.Equals(value, "any", StringComparison.Ordinal) - || string.Equals(value, "main", StringComparison.Ordinal) - || string.Equals(value, "unknown", StringComparison.Ordinal); + || string.Equals(value, "main", StringComparison.Ordinal) + || string.Equals(value, "unknown", StringComparison.Ordinal); } private static bool IsOwnership(string value) { return string.Equals(value, "none", StringComparison.Ordinal) - || string.Equals(value, "borrowed", StringComparison.Ordinal) - || string.Equals(value, "owned", StringComparison.Ordinal); + || string.Equals(value, "borrowed", StringComparison.Ordinal) + || string.Equals(value, "owned", StringComparison.Ordinal); } private static bool IsNilSemantics(string value) { return string.Equals(value, "none", StringComparison.Ordinal) - || string.Equals(value, "absence", StringComparison.Ordinal) - || string.Equals(value, "expected-failure", StringComparison.Ordinal) - || string.Equals(value, "lua-error", StringComparison.Ordinal); + || string.Equals(value, "absence", StringComparison.Ordinal) + || string.Equals(value, "expected-failure", StringComparison.Ordinal) + || string.Equals(value, "lua-error", StringComparison.Ordinal); } private static SpecCallModel? ParseEntry(Block block, List issues, SpecFileContract? fileContract) @@ -566,7 +566,7 @@ private static bool IsNilSemantics(string value) } if (!ValidateRequiredText(fields, block.StartLine, fileContract is not null, issues, out bool isTry, - out bool isThrowing)) + out bool isThrowing)) { return null; } @@ -597,7 +597,7 @@ private static bool IsNilSemantics(string value) } if (!TryParseReturnKind(fields, isThrowing, block.StartLine, issues, out LuaValueKind? returnKind, - out bool returnIsNullable)) + out bool returnIsNullable)) { return null; } @@ -668,12 +668,18 @@ private static bool TrySetEntryField(EntryFields fields, HashSet singula { switch (field.Key) { - case "global": return TrySetGlobal(fields, singular, field, issues); - case "method": return TrySetMethod(fields, singular, field, issues); - case "form": return TrySetForm(fields, singular, field, issues); - case "doc": return TrySetDoc(fields, singular, field, issues); - case "nil": return TrySetNil(fields, singular, field, issues); - case "return": return TrySetReturn(fields, singular, field, issues); + case "global": + return TrySetGlobal(fields, singular, field, issues); + case "method": + return TrySetMethod(fields, singular, field, issues); + case "form": + return TrySetForm(fields, singular, field, issues); + case "doc": + return TrySetDoc(fields, singular, field, issues); + case "nil": + return TrySetNil(fields, singular, field, issues); + case "return": + return TrySetReturn(fields, singular, field, issues); case "arg": fields.ArgTokens.Add((field.Line, field.ValueColumn, field.Value)); return true; @@ -747,7 +753,7 @@ private static bool ValidateRequiredText(EntryFields fields, int startLine, bool isThrowing = false; if (!ValidateRequiredPresence(fields, startLine, issues) - || !ValidateNilContract(fields, startLine, requiresCe77Contract, issues)) + || !ValidateNilContract(fields, startLine, requiresCe77Contract, issues)) { return false; } @@ -845,8 +851,8 @@ private static bool TryParseReturnKind( foreach ((int line, int column, string value) in tokens) { if (!TryParseNamedValue(value, out string name, out string kindToken) - || !SpecIdentifiers.IsValidIdentifier(name) - || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) + || !SpecIdentifiers.IsValidIdentifier(name) + || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) { issues.Add(new SpecIssue(line, "'" + value + "' is not a valid 'name:kind' argument.", column)); return null; @@ -868,9 +874,9 @@ private static bool TryParseReturnKind( foreach ((int line, int column, string value) in tokens) { if (!TryParseNamedValue(value, out string kindToken, out string literal) - || !string.Equals(kindToken, "boolean", StringComparison.Ordinal) - || !(string.Equals(literal, "true", StringComparison.Ordinal) - || string.Equals(literal, "false", StringComparison.Ordinal))) + || !string.Equals(kindToken, "boolean", StringComparison.Ordinal) + || !(string.Equals(literal, "true", StringComparison.Ordinal) + || string.Equals(literal, "false", StringComparison.Ordinal))) { issues.Add(new SpecIssue(line, "'" + value + "' is not a valid fixed argument: expected 'boolean:true' or 'boolean:false'.", @@ -891,8 +897,8 @@ private static bool TryParseReturnKind( foreach ((int line, int column, string value) in tokens) { if (!TryParseNamedValue(value, out string name, out string kindToken) - || !SpecIdentifiers.IsValidIdentifier(name) - || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) + || !SpecIdentifiers.IsValidIdentifier(name) + || !SpecValueKinds.TryParse(kindToken, out LuaValueKind kind, out bool nullable)) { issues.Add(new SpecIssue(line, "'" + value + "' is not a valid 'name:kind' result.", column)); return null; @@ -1096,11 +1102,11 @@ private static bool ValidateNilContract(EntryFields fields, int startLine, bool private static bool IsReservedBodyLocal(string name, LuaGlobalCallModel call) { if (string.Equals(name, "__L", StringComparison.Ordinal) - || string.Equals(name, "__operation", StringComparison.Ordinal) - || string.Equals(name, "__top", StringComparison.Ordinal) - || string.Equals(name, "__ok", StringComparison.Ordinal) - || string.Equals(name, "__status", StringComparison.Ordinal) - || string.Equals(name, "__result", StringComparison.Ordinal)) + || string.Equals(name, "__operation", StringComparison.Ordinal) + || string.Equals(name, "__top", StringComparison.Ordinal) + || string.Equals(name, "__ok", StringComparison.Ordinal) + || string.Equals(name, "__status", StringComparison.Ordinal) + || string.Equals(name, "__result", StringComparison.Ordinal)) { return true; } @@ -1111,7 +1117,7 @@ private static bool IsReservedBodyLocal(string name, LuaGlobalCallModel call) } if (string.Equals(name, "__engineApiSucceeded", StringComparison.Ordinal) - || string.Equals(name, "__engineApiRawResult", StringComparison.Ordinal)) + || string.Equals(name, "__engineApiRawResult", StringComparison.Ordinal)) { return true; } @@ -1124,7 +1130,7 @@ private static bool IsReservedBodyLocal(string name, LuaGlobalCallModel call) for (int i = 0; i < call.Results.Length; i++) { if (call.Results[i].Kind == LuaValueKind.Address - && string.Equals(name, RawResultName(i), StringComparison.Ordinal)) + && string.Equals(name, RawResultName(i), StringComparison.Ordinal)) { return true; } @@ -1192,8 +1198,8 @@ private static List DropCacheMemberCollisions(List foreach (SpecCallModel entry in entries) { bool conflicts = cacheFields.Contains(entry.Call.MethodName) - || (UsesAddressFacade(entry.Call) && - cacheFields.Contains(CoreMethodName(entry.Call.MethodName))); + || (UsesAddressFacade(entry.Call) && + cacheFields.Contains(CoreMethodName(entry.Call.MethodName))); if (!conflicts) { valid.Add(entry); @@ -1219,9 +1225,9 @@ private static List DropTypeMemberCollisions(List foreach (SpecCallModel entry in entries) { bool conflicts = string.Equals(entry.Call.MethodName, typeName, StringComparison.Ordinal) - || (UsesAddressFacade(entry.Call) - && string.Equals(CoreMethodName(entry.Call.MethodName), typeName, - StringComparison.Ordinal)); + || (UsesAddressFacade(entry.Call) + && string.Equals(CoreMethodName(entry.Call.MethodName), typeName, + StringComparison.Ordinal)); if (!conflicts) { valid.Add(entry); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs index ffbb1dcd..6641af32 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Emit/BootstrapEmitter.cs @@ -95,8 +95,8 @@ public static SourceText Emit(BootstrapModel model) internal static string ChooseFactoryName(string fullyQualifiedPluginTypeName) { bool collides = fullyQualifiedPluginTypeName.StartsWith(QualifiedFactoryName, StringComparison.Ordinal) - && (fullyQualifiedPluginTypeName.Length == QualifiedFactoryName.Length - || fullyQualifiedPluginTypeName[QualifiedFactoryName.Length] == '.'); + && (fullyQualifiedPluginTypeName.Length == QualifiedFactoryName.Length + || fullyQualifiedPluginTypeName[QualifiedFactoryName.Length] == '.'); return collides ? AlternateFactoryName : FactoryName; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs index 7832ce47..8e008a51 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointContractSymbols.cs @@ -93,7 +93,7 @@ public static EntryPointContractSymbols Resolve(Compilation compilation) foreach (MetadataReference reference in compilation.References) { if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly - && string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) + && string.Equals(assembly.Identity.Name, assemblyName, StringComparison.Ordinal)) { return assembly.GetTypeByMetadataName(metadataName); } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs index 5ba484e2..1e77ee26 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointDeclaredDiagnosticIds.cs @@ -82,8 +82,8 @@ private static void CollectFrom( foreach (AttributeData attribute in attributes) { if (ReadDeclaredId(attribute, experimentalAttribute, obsoleteAttribute) is { } id - && IsUsableInPragma(id) - && (ids is null || !ids.Contains(id))) + && IsUsableInPragma(id) + && (ids is null || !ids.Contains(id))) { (ids ??= []).Add(id); } @@ -96,24 +96,24 @@ private static void CollectFrom( INamedTypeSymbol? obsoleteAttribute) { if (experimentalAttribute is not null - && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, experimentalAttribute)) - // [Experimental(string diagnosticId)] + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, experimentalAttribute)) + // [Experimental(string diagnosticId)] { return attribute.ConstructorArguments.Length == 1 - && attribute.ConstructorArguments[0] is - { Kind: TypedConstantKind.Primitive, Value: string experimentalId } + && attribute.ConstructorArguments[0] is + { Kind: TypedConstantKind.Primitive, Value: string experimentalId } ? experimentalId : null; } if (obsoleteAttribute is not null - && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute)) - // [Obsolete(..., DiagnosticId = "ID")] + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute)) + // [Obsolete(..., DiagnosticId = "ID")] { foreach (KeyValuePair argument in attribute.NamedArguments) { if (string.Equals(argument.Key, "DiagnosticId", StringComparison.Ordinal) - && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) + && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) { return obsoleteId; } @@ -129,7 +129,7 @@ private static void CollectFrom( private static bool IsUsableInPragma(string id) { return SyntaxFacts.IsValidIdentifier(id) - && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None - && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; + && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None + && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs index 4620c2ca..b32aaa9d 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.EntryPoint/Parsing/EntryPointGeneratedIdentity.cs @@ -29,8 +29,8 @@ public static bool HasEntryPointTypeCollision(Compilation compilation) foreach (INamedTypeSymbol type in @namespace.GetTypeMembers(ManagedEntryPointNames.TypeName)) { if (type.Arity == 0 - && !type.IsFileLocal - && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilation.Assembly)) + && !type.IsFileLocal + && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilation.Assembly)) { return true; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs index 7af12ef8..11eef56a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emit/LuaObjectMembersFileEmitter.cs @@ -215,7 +215,7 @@ private static void WriteStackCheck(SourceWriter writer, LuaObjectMethodModel mo writer.Write('('); writer.Write(CSharpLiteral.ToStringLiteral( "The Lua stack could not grow by " + requiredSlots.ToString(CultureInfo.InvariantCulture) - + " slots to call '" + model.LuaName + "'.")); + + " slots to call '" + model.LuaName + "'.")); writer.WriteLine(");"); } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs index 41dc2aa4..4a6510f0 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionModel.cs @@ -35,5 +35,5 @@ internal sealed record LuaFunctionModel( /// group). /// public bool IsValid => Issues == LuaFunctionShapeIssues.None && ContainingTypeIssues == ContainingTypeIssues.None && - Thunk is not null && !HasGeneratedIdentityCollision; + Thunk is not null && !HasGeneratedIdentityCollision; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs index 203502fc..d6b50a7f 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaFunctionTables.cs @@ -67,7 +67,10 @@ private static List AssignHintNames(List when a body can be emitted for this method. public bool IsValid => Issues == LuaGlobalShapeIssues.None && ContainingTypeIssues == ContainingTypeIssues.None && - Call is not null && !HasGeneratedIdentityCollision; + Call is not null && !HasGeneratedIdentityCollision; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs index 8b4cf4b5..d865efd9 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Model/LuaGlobalTables.cs @@ -85,7 +85,10 @@ private static List AssignHintNames(List tables) for (int i = 0; i < tables.Count; i++) { string baseName = tables[i].ContainingType.HintBaseName; - tables[i] = tables[i] with { HintName = HintNames.AllocateUnique(baseName, HintSuffix, used) }; + tables[i] = tables[i] with + { + HintName = HintNames.AllocateUnique(baseName, HintSuffix, used) + }; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs index 94a58a4f..fa885b88 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/ContainingTypeParser.cs @@ -75,7 +75,7 @@ private static bool IsReadOnly(INamedTypeSymbol type) foreach (SyntaxReference reference in type.DeclaringSyntaxReferences) { if (reference.GetSyntax() is TypeDeclarationSyntax declaration - && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) + && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) { return true; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs index ea7f0b3f..8d08bae3 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaBindingsDeclaredDiagnosticIds.cs @@ -81,24 +81,24 @@ public static string Collect(IMethodSymbol method) } if (string.Equals(attributeClass.Name, "ExperimentalAttribute", StringComparison.Ordinal)) - // [Experimental(string diagnosticId)] + // [Experimental(string diagnosticId)] { return IsNamespace(attributeClass.ContainingNamespace, "System", "Diagnostics", "CodeAnalysis") - && attribute.ConstructorArguments.Length == 1 - && attribute.ConstructorArguments[0] is - { Kind: TypedConstantKind.Primitive, Value: string experimentalId } + && attribute.ConstructorArguments.Length == 1 + && attribute.ConstructorArguments[0] is + { Kind: TypedConstantKind.Primitive, Value: string experimentalId } ? experimentalId : null; } if (string.Equals(attributeClass.Name, "ObsoleteAttribute", StringComparison.Ordinal) - && IsNamespace(attributeClass.ContainingNamespace, "System")) - // [Obsolete(..., DiagnosticId = "ID")] + && IsNamespace(attributeClass.ContainingNamespace, "System")) + // [Obsolete(..., DiagnosticId = "ID")] { foreach (KeyValuePair argument in attribute.NamedArguments) { if (string.Equals(argument.Key, "DiagnosticId", StringComparison.Ordinal) - && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) + && argument.Value is { Kind: TypedConstantKind.Primitive, Value: string obsoleteId }) { return obsoleteId; } @@ -130,7 +130,7 @@ private static bool IsNamespace(INamespaceSymbol? @namespace, params string[] na private static bool IsUsableInPragma(string id) { return SyntaxFacts.IsValidIdentifier(id) - && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None - && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; + && SyntaxFacts.GetKeywordKind(id) == SyntaxKind.None + && SyntaxFacts.GetPreprocessorKeywordKind(id) == SyntaxKind.None; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs index bf4789c0..43a5d565 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaClassParser.cs @@ -27,7 +27,7 @@ public static LuaClassModel Parse(GeneratorAttributeSyntaxContext context, Cance string? luaName = LuaBindingSymbols.ReadSdkAttributeName(context.Attributes, compilation, LuaBindingsGenerator.LuaClassAttributeMetadataName); bool isValid = isSdkAttribute && LuaNames.IsValidName(luaName) - && IsBorrowedHandleShape(type, compilation, cancellationToken); + && IsBorrowedHandleShape(type, compilation, cancellationToken); return new LuaClassModel(ContainingTypeParser.Parse(type), luaName ?? string.Empty, isValid); } @@ -71,7 +71,7 @@ internal static bool IsGeneratedHandle(INamedTypeSymbol type, Compilation compil { ImmutableArray attributes = ImmutableArray.Create(attribute); if (LuaBindingSymbols.ContainsSdkAttribute(attributes, compilation, - LuaBindingsGenerator.LuaClassAttributeMetadataName)) + LuaBindingsGenerator.LuaClassAttributeMetadataName)) { return LuaNames.IsValidName(LuaBindingSymbols.ReadSdkAttributeName( attributes, @@ -89,7 +89,7 @@ private static bool IsReadOnlyStruct(INamedTypeSymbol type, CancellationToken ca { cancellationToken.ThrowIfCancellationRequested(); if (reference.GetSyntax(cancellationToken) is StructDeclarationSyntax declaration - && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) + && declaration.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) { return true; } @@ -103,10 +103,10 @@ private static bool IsReadOnlyStruct(INamedTypeSymbol type, CancellationToken ca private static bool HasGeneratedIdentityCollision(INamedTypeSymbol type, INamedTypeSymbol? ceObject) { return LuaClassGeneratedNames.IsGeneratedType(type.Name) - || HasGeneratedMember(type, ceObject) - || HasMember(type, "op_Equality") - || HasMember(type, "op_Inequality") - || HasCEObjectConstructor(type, ceObject); + || HasGeneratedMember(type, ceObject) + || HasMember(type, "op_Equality") + || HasMember(type, "op_Inequality") + || HasCEObjectConstructor(type, ceObject); } private static bool HasCEObjectConstructor(INamedTypeSymbol type, INamedTypeSymbol? ceObject) @@ -125,7 +125,7 @@ private static bool HasCEObjectConstructor(INamedTypeSymbol type, INamedTypeSymb IParameterSymbol parameter = constructor.Parameters[0]; if (parameter.RefKind == RefKind.None - && SymbolEqualityComparer.Default.Equals(parameter.Type, ceObject)) + && SymbolEqualityComparer.Default.Equals(parameter.Type, ceObject)) { return true; } @@ -139,7 +139,7 @@ private static bool HasGeneratedMember(INamedTypeSymbol type, INamedTypeSymbol? foreach (ISymbol member in type.GetMembers()) { if (LuaClassGeneratedNames.IsGeneratedMember(member.Name) - || LuaClassGeneratedNames.IsGeneratedAccessorCollision(member, ceObject)) + || LuaClassGeneratedNames.IsGeneratedAccessorCollision(member, ceObject)) { return true; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs index ad538b28..5d1b10a7 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaFunctionParser.cs @@ -65,13 +65,13 @@ private static bool HasGeneratedIdentityCollision(IMethodSymbol method, string? { INamedTypeSymbol type = method.ContainingType; if (type.GetMembers(LuaRegistrationEmitter.RegisterMethodName).Length != 0 - || type.GetMembers(LuaRegistrationEmitter.RegisterLeaseMethodName).Length != 0 - || type.GetMembers(LuaRegistrationEmitter.UnregisterMethodName).Length != 0) + || type.GetMembers(LuaRegistrationEmitter.RegisterLeaseMethodName).Length != 0 + || type.GetMembers(LuaRegistrationEmitter.UnregisterMethodName).Length != 0) { return true; } return LuaNames.IsValidName(luaName) - && type.GetMembers(LuaThunkModel.ThunkNameFor(luaName!)).Length != 0; + && type.GetMembers(LuaThunkModel.ThunkNameFor(luaName!)).Length != 0; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs index 4faf5d75..3eea4a75 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaGlobalParser.cs @@ -151,13 +151,13 @@ private static bool HasGeneratedIdentityCollision(IMethodSymbol method, string? foreach (IParameterSymbol parameter in method.Parameters) { if (parameter.Name is "__L" or "__operation" or "__top" or "__ok" or "__status" or "__result" or - "__resolution" or "__exception") + "__resolution" or "__exception") { return true; } } return LuaNames.IsValidName(luaName) - && method.ContainingType.GetMembers(LuaGlobalCallModel.CacheFieldFor(luaName!)).Length != 0; + && method.ContainingType.GetMembers(LuaGlobalCallModel.CacheFieldFor(luaName!)).Length != 0; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs index 7f6a79af..cc8147cd 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectMethodParser.cs @@ -38,13 +38,17 @@ public static LuaObjectMethodModel Parse(GeneratorAttributeSyntaxContext context bool described = TryDescribe(method, context.TargetNode as MethodDeclarationSyntax, out LuaObjectMethodModel model); bool valid = isSdkAttribute && LuaNames.IsValidName(luaName) - && LuaClassParser.IsGeneratedHandle(method.ContainingType, compilation, - cancellationToken) - && described; + && LuaClassParser.IsGeneratedHandle(method.ContainingType, compilation, + cancellationToken) + && described; if (valid) { - return model with { LuaName = luaName!, IsValid = true }; + return model with + { + LuaName = luaName!, + IsValid = true + }; } return new LuaObjectMethodModel( @@ -68,11 +72,11 @@ private static bool TryDescribe(IMethodSymbol method, MethodDeclarationSyntax? d ImmutableArray.CreateBuilder(method.Parameters.Length); ImmutableArray.Builder results = ImmutableArray.CreateBuilder(); bool valid = method.MethodKind == MethodKind.Ordinary - && !method.IsStatic - && method.IsPartialDefinition - && method.PartialImplementationPart is null - && !method.IsGenericMethod - && !method.IsAsync; + && !method.IsStatic + && method.IsPartialDefinition + && method.PartialImplementationPart is null + && !method.IsGenericMethod + && !method.IsAsync; valid &= DescribeParameters(method.Parameters, arguments, results); LuaCallForm form = results.Count == 0 ? LuaCallForm.Throwing : LuaCallForm.Try; @@ -103,7 +107,7 @@ private static bool DescribeParameters(ImmutableArray paramete foreach (IParameterSymbol parameter in parameters) { if (IsReserved(parameter.Name) || parameter.IsParams || parameter.IsOptional - || parameter.HasExplicitDefaultValue) + || parameter.HasExplicitDefaultValue) { valid = false; } @@ -112,7 +116,7 @@ private static bool DescribeParameters(ImmutableArray paramete { seenResult = true; if (!LuaValueKindMapper.TryMap(parameter.Type, out LuaValueKind kind, out bool nullable) - || !LuaValueKinds.CanBeResult(kind)) + || !LuaValueKinds.CanBeResult(kind)) { valid = false; } @@ -125,9 +129,9 @@ private static bool DescribeParameters(ImmutableArray paramete } if (parameter.RefKind != RefKind.None || seenResult - || !LuaValueKindMapper.TryMap(parameter.Type, - out LuaValueKind argumentKind, - out bool argumentNullable)) + || !LuaValueKindMapper.TryMap(parameter.Type, + out LuaValueKind argumentKind, + out bool argumentNullable)) { valid = false; } @@ -149,7 +153,7 @@ private static bool TryDescribeReturn(IMethodSymbol method, LuaCallForm form, ou if (form == LuaCallForm.Try) { return method.ReturnType.SpecialType == SpecialType.System_Boolean && !method.ReturnsByRef - && !method.ReturnsByRefReadonly; + && !method.ReturnsByRefReadonly; } if (method.ReturnsVoid) @@ -158,9 +162,9 @@ private static bool TryDescribeReturn(IMethodSymbol method, LuaCallForm form, ou } if (method.ReturnsByRef || method.ReturnsByRefReadonly - || !LuaValueKindMapper.TryMap(method.ReturnType, out LuaValueKind kind, - out returnNullable) - || !LuaValueKinds.CanBeResult(kind)) + || !LuaValueKindMapper.TryMap(method.ReturnType, out LuaValueKind kind, + out returnNullable) + || !LuaValueKinds.CanBeResult(kind)) { return false; } @@ -172,10 +176,10 @@ private static bool TryDescribeReturn(IMethodSymbol method, LuaCallForm form, ou private static bool IsReserved(string name) { return string.Equals(name, StateLocal, StringComparison.Ordinal) - || string.Equals(name, OperationLocal, StringComparison.Ordinal) - || string.Equals(name, TopLocal, StringComparison.Ordinal) - || string.Equals(name, StatusLocal, StringComparison.Ordinal) - || string.Equals(name, ResultLocal, StringComparison.Ordinal); + || string.Equals(name, OperationLocal, StringComparison.Ordinal) + || string.Equals(name, TopLocal, StringComparison.Ordinal) + || string.Equals(name, StatusLocal, StringComparison.Ordinal) + || string.Equals(name, ResultLocal, StringComparison.Ordinal); } private static string Modifiers(MethodDeclarationSyntax? declaration) diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs index 1efea72f..5e3513fa 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Parsing/LuaObjectPropertyParser.cs @@ -29,13 +29,17 @@ public static LuaObjectPropertyModel Parse(GeneratorAttributeSyntaxContext conte LuaBindingsGenerator.LuaPropertyAttributeMetadataName); bool described = TryDescribe(property, declaration, out LuaObjectPropertyModel model); bool valid = isSdkAttribute && LuaNames.IsValidName(luaName) - && LuaClassParser.IsGeneratedHandle(property.ContainingType, compilation, - cancellationToken) - && described; + && LuaClassParser.IsGeneratedHandle(property.ContainingType, compilation, + cancellationToken) + && described; if (valid) { - return model with { LuaName = luaName!, IsValid = true }; + return model with + { + LuaName = luaName!, + IsValid = true + }; } return new LuaObjectPropertyModel( @@ -60,7 +64,7 @@ private static bool TryDescribe(IPropertySymbol property, PropertyDeclarationSyn bool isNullable = false; string modifiers = string.Empty; bool typeIsSupported = LuaValueKindMapper.TryMap(property.Type, out kind, out isNullable) - && LuaValueKinds.CanBeResult(kind); + && LuaValueKinds.CanBeResult(kind); bool definitionIsSupported = IsSupportedDefinition(property, declaration, typeIsSupported, out modifiers); bool accessorsAreSupported = TryDescribeAccessors(declaration, out bool hasGetter, out string getterModifiers, @@ -88,15 +92,15 @@ private static bool IsSupportedDefinition(IPropertySymbol property, PropertyDecl { modifiers = string.Empty; return declaration is not null - && !property.IsStatic - && !property.IsIndexer - && property.RefKind == RefKind.None - && declaration.Modifiers.Any(SyntaxKind.PartialKeyword) - && declaration.AccessorList is not null - && declaration.ExplicitInterfaceSpecifier is null - && property.PartialImplementationPart is null - && typeIsSupported - && TryModifiers(declaration, out modifiers); + && !property.IsStatic + && !property.IsIndexer + && property.RefKind == RefKind.None + && declaration.Modifiers.Any(SyntaxKind.PartialKeyword) + && declaration.AccessorList is not null + && declaration.ExplicitInterfaceSpecifier is null + && property.PartialImplementationPart is null + && typeIsSupported + && TryModifiers(declaration, out modifiers); } private static bool TryDescribeAccessors(PropertyDeclarationSyntax? declaration, out bool hasGetter, @@ -114,7 +118,7 @@ private static bool TryDescribeAccessors(PropertyDeclarationSyntax? declaration, foreach (AccessorDeclarationSyntax accessor in accessorList.Accessors) { if (!TryDescribeAccessor(accessor, ref hasGetter, ref getterModifiers, ref hasSetter, - ref setterModifiers)) + ref setterModifiers)) { return false; } @@ -127,7 +131,7 @@ private static bool TryDescribeAccessor(AccessorDeclarationSyntax accessor, ref ref string getterModifiers, ref bool hasSetter, ref string setterModifiers) { if (accessor.Body is not null || accessor.ExpressionBody is not null - || !TryAccessorModifiers(accessor, out string modifiers)) + || !TryAccessorModifiers(accessor, out string modifiers)) { return false; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs index dcf133dc..52ab476a 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.LuaBridgeContract/Catalog/ProtectedOperationCatalogParser.cs @@ -149,7 +149,7 @@ private static void ParseOperation( ref ulong bitmap) { if (!TryReadOperationFields(value, reader, diagnostics, out JsonString idValue, out JsonNumber opcodeValue, - out JsonString? managedConstant)) + out JsonString? managedConstant)) { return; } @@ -162,7 +162,7 @@ private static void ParseOperation( } if (managedConstant is not null && - !string.Equals(managedConstant.Text, idValue.Text + "Operation", StringComparison.Ordinal)) + !string.Equals(managedConstant.Text, idValue.Text + "Operation", StringComparison.Ordinal)) { diagnostics.Add(reader.CreateDiagnostic(managedConstant.Span, "Property 'managed.constant' must be '" + idValue.Text + "Operation' for operation '" + idValue.Text + @@ -170,8 +170,8 @@ private static void ParseOperation( } if (!int.TryParse(opcodeValue.Text, NumberStyles.None, CultureInfo.InvariantCulture, out int opcode) - || opcode < 0 - || opcode > 63) + || opcode < 0 + || opcode > 63) { diagnostics.Add(reader.CreateDiagnostic(opcodeValue.Span, "Operation 'opcode' must be an integer between 0 and 63.")); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs index a2925b84..eb75fc03 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/GeneratedCodeText.cs @@ -76,9 +76,9 @@ public static string CreateGeneratedCodeAttribute(Type generatorType) AssemblyName assembly = generatorType.Assembly.GetName(); string version = (assembly.Version ?? new Version(0, 0, 0, 0)).ToString(); return "[global::System.CodeDom.Compiler.GeneratedCode(" - + CSharpLiteral.ToStringLiteral(assembly.Name ?? string.Empty) - + ", " - + CSharpLiteral.ToStringLiteral(version) - + ")]"; + + CSharpLiteral.ToStringLiteral(assembly.Name ?? string.Empty) + + ", " + + CSharpLiteral.ToStringLiteral(version) + + ")]"; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs index fc7a6c50..78e634dd 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/HintNames.cs @@ -103,7 +103,7 @@ public static string AllocateUnique(string typeName, string suffix, HashSet p { _inResults = true; if (index + 1 >= parameters.Length - || parameters[index + 1] is - not { RefKind: RefKind.Out, Type.SpecialType: SpecialType.System_Int32 } written) + || parameters[index + 1] is + not { RefKind: RefKind.Out, Type.SpecialType: SpecialType.System_Int32 } written) { return LuaGlobalShapeIssues.UnsupportedResultType; } @@ -338,8 +338,8 @@ private LuaGlobalShapeIssues AddArgument(Compilation? compilation, INamedTypeSym } if (!LuaMarshallerResolver.TryResolve(compilation, bindingType, parameter.Type, parameter.GetAttributes(), - luaMarshallerAttribute, luaMarshallerContract, out LuaCustomMarshallerModel? customMarshaller, - out _)) + luaMarshallerAttribute, luaMarshallerContract, out LuaCustomMarshallerModel? customMarshaller, + out _)) { return issues | LuaGlobalShapeIssues.UnsupportedParameterType; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs index fe7cd52d..b866c2d5 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaMarshallerResolver.cs @@ -57,7 +57,7 @@ public static bool TryResolve(Compilation? compilation, INamedTypeSymbol binding } if (!Implements(type, marshallerContract, valueType) - || !HasCallableStaticContract(compilation, bindingType, type, marshallerContract, valueType)) + || !HasCallableStaticContract(compilation, bindingType, type, marshallerContract, valueType)) { return false; } @@ -74,7 +74,7 @@ private static bool Implements(INamedTypeSymbol candidate, INamedTypeSymbol cont foreach (INamedTypeSymbol implementation in candidate.AllInterfaces) { if (!SymbolEqualityComparer.Default.Equals(implementation.OriginalDefinition, contract) - || implementation.TypeArguments.Length != 1) + || implementation.TypeArguments.Length != 1) { continue; } @@ -101,7 +101,7 @@ private static bool HasCallableStaticContract(Compilation? compilation, INamedTy INamedTypeSymbol closedContract = contract.Construct(valueType); return HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "Push") - && HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "TryRead"); + && HasCallableStaticMethod(compilation, bindingType, candidate, closedContract, "TryRead"); } private static bool HasCallableStaticMethod(Compilation? compilation, INamedTypeSymbol bindingType, @@ -125,12 +125,12 @@ private static bool HasCallableStaticMethod(Compilation? compilation, INamedType foreach (ISymbol member in candidate.GetMembers(name)) { if (member is not IMethodSymbol method - || method.MethodKind != MethodKind.Ordinary - || !method.IsStatic - || method.IsAbstract - || method.Arity != 0 - || (compilation is not null && !compilation.IsSymbolAccessibleWithin(method, bindingType)) - || !HasMatchingSignature(method, required)) + || method.MethodKind != MethodKind.Ordinary + || !method.IsStatic + || method.IsAbstract + || method.Arity != 0 + || (compilation is not null && !compilation.IsSymbolAccessibleWithin(method, bindingType)) + || !HasMatchingSignature(method, required)) { continue; } @@ -144,8 +144,8 @@ private static bool HasCallableStaticMethod(Compilation? compilation, INamedType private static bool HasMatchingSignature(IMethodSymbol candidate, IMethodSymbol required) { if (candidate.ReturnsVoid != required.ReturnsVoid - || !SymbolEqualityComparer.Default.Equals(candidate.ReturnType, required.ReturnType) - || candidate.Parameters.Length != required.Parameters.Length) + || !SymbolEqualityComparer.Default.Equals(candidate.ReturnType, required.ReturnType) + || candidate.Parameters.Length != required.Parameters.Length) { return false; } @@ -155,7 +155,7 @@ private static bool HasMatchingSignature(IMethodSymbol candidate, IMethodSymbol IParameterSymbol actual = candidate.Parameters[i]; IParameterSymbol expected = required.Parameters[i]; if (actual.RefKind != expected.RefKind - || !SymbolEqualityComparer.Default.Equals(actual.Type, expected.Type)) + || !SymbolEqualityComparer.Default.Equals(actual.Type, expected.Type)) { return false; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs index 721e5a20..21c79a6b 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaBindings/Parsing/LuaValueKindMapper.cs @@ -90,8 +90,8 @@ public static bool IsSpanOfByte(ITypeSymbol type) private static bool IsSystemSpanOfByte(ITypeSymbol type, string name) { return type is INamedTypeSymbol { Arity: 1, ContainingType: null } named - && string.Equals(named.Name, name, StringComparison.Ordinal) - && named.TypeArguments[0].SpecialType == SpecialType.System_Byte - && named.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true }; + && string.Equals(named.Name, name, StringComparison.Ordinal) + && named.TypeArguments[0].SpecialType == SpecialType.System_Byte + && named.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true }; } } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs index 70f2df48..7cb5f872 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs @@ -556,7 +556,7 @@ private static void WriteThrowingCall(SourceWriter writer, int argumentCount, in private static void WriteThrowingResult(SourceWriter writer, LuaGlobalCallModel model) { if (!model.HasReturn) - // A void call keeps no result: the successful call already left the stack at its recorded top. + // A void call keeps no result: the successful call already left the stack at its recorded top. { return; } diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs index c2a976c7..aec7bf73 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallModel.cs @@ -91,15 +91,15 @@ public LuaGlobalCallModel(string globalName, string cacheFieldName, string modif /// The concrete static marshaller for the throwing-form return value. public string ReturnMarshallerTypeName => ReturnMarshaller?.MarshallerTypeName ?? - LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); + LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); /// The C# type spelling for the generated return and result local. public string ReturnTypeName => ReturnMarshaller?.ValueTypeName ?? - LuaValueKinds.TypeName(ReturnKind!.Value, ReturnIsNullable); + LuaValueKinds.TypeName(ReturnKind!.Value, ReturnIsNullable); /// The Lua-facing expected type for a throwing-form result failure. public string ExpectedReturnTypeName => ReturnMarshaller?.ExpectedTypeName ?? - LuaValueKinds.ExpectedResult(ReturnKind!.Value); + LuaValueKinds.ExpectedResult(ReturnKind!.Value); /// Whether the body reads the state from rather than from the runtime. public bool TakesState => StateParameterName.Length > 0; diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs index 9d66bc6d..8a8b1cc8 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkEmitter.cs @@ -101,7 +101,7 @@ public static void Emit(SourceWriter writer, LuaThunkModel model) public static string WrongArgumentCountMessage(string luaName, int expected) { return "wrong number of arguments to '" + luaName + "' (" + expected.ToString(CultureInfo.InvariantCulture) + - " expected)"; + " expected)"; } // The count check first, so that a missing argument and a surplus one get the same, complete message. diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs index aca4152d..a6eecd6e 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaThunkModel.cs @@ -55,7 +55,7 @@ public LuaThunkModel(string luaName, string thunkMethodName, string targetMethod /// The concrete static marshaller for the return value. public string ReturnMarshallerTypeName => ReturnMarshaller?.MarshallerTypeName ?? - LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); + LuaValueKinds.MarshallerTypeName(ReturnKind!.Value); /// The C# type spelling for the generated result local. public string ReturnTypeName => ReturnMarshaller?.ValueTypeName ?? LuaValueKinds.TypeName(ReturnKind!.Value, true); diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs index 98d6924b..1dde5b55 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs @@ -124,8 +124,8 @@ public static PluginShapeIssues Inspect( parameterlessConstructor = null; if (type.IsStatic) - // A static class is also abstract and sealed in metadata, has no base class and no instance - // constructor: one message instead of four. + // A static class is also abstract and sealed in metadata, has no base class and no instance + // constructor: one message instead of four. { return issues | PluginShapeIssues.Static; } @@ -229,8 +229,8 @@ private static bool DerivesFromPluginBase(INamedTypeSymbol type, INamedTypeSymbo for (INamedTypeSymbol? current = type.BaseType; current is not null; current = current.BaseType) { if (pluginBase is null - ? IsPluginBaseFallback(current) - : SymbolEqualityComparer.Default.Equals(current, pluginBase)) + ? IsPluginBaseFallback(current) + : SymbolEqualityComparer.Default.Equals(current, pluginBase)) { return true; } @@ -329,7 +329,7 @@ private static bool HasRequiredMembers(INamedTypeSymbol type) for (INamedTypeSymbol? current = type; current is not null; current = current.BaseType) { if (current.GetMembers().Any(static member => - member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true })) + member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true })) { return true; } diff --git a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs index 5439e8b8..acc51c41 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs @@ -56,8 +56,8 @@ public void Header_derived_classic_records_have_the_fixture_x64_alignments() public void Header_and_pinned_Pascal_popup_contract_conflict_keeps_the_slot_opaque_until_a_live_canary() { FieldInfo popup = typeof(DisassemblerContextPluginInit).GetField( - nameof(DisassemblerContextPluginInit.CallbackOnPopup)) - ?? throw new InvalidOperationException("The popup callback field was not found."); + nameof(DisassemblerContextPluginInit.CallbackOnPopup)) + ?? throw new InvalidOperationException("The popup callback field was not found."); Type fieldType = popup.GetModifiedFieldType().UnderlyingSystemType; diff --git a/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs index 395d218b..4fc5157b 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs @@ -117,7 +117,7 @@ private static Bool32 FakeGetVersion(PluginVersion* version, int size) private static Bool32 FakeEnablePlugin(ManagedExportedFunctions* exports, uint pluginId) { return exports is not null && exports->SizeOfExportedFunctions == sizeof(ManagedExportedFunctions) && - pluginId == 0xFFFF_FFF0u; + pluginId == 0xFFFF_FFF0u; } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs index 23427dc6..d3007679 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs @@ -92,14 +92,14 @@ public void TryCopy_copies_exactly_the_qualified_prefix_without_overflow(int dec public void Prefix_distinguishes_direct_function_slots_value_cells_and_opaque_null_slots_without_invocation() { FieldInfo showMessage = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.ShowMessage)) - ?? throw new InvalidOperationException("The ShowMessage field was not found."); + ?? throw new InvalidOperationException("The ShowMessage field was not found."); FieldInfo processId = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.OpenedProcessId)) - ?? throw new InvalidOperationException("The OpenedProcessId field was not found."); + ?? throw new InvalidOperationException("The OpenedProcessId field was not found."); FieldInfo processHandle = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.OpenedProcessHandle)) ?? throw new InvalidOperationException("The OpenedProcessHandle field was not found."); FieldInfo fixMemory = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.FixMemory)) - ?? throw new InvalidOperationException("The FixMemory field was not found."); + ?? throw new InvalidOperationException("The FixMemory field was not found."); FieldInfo getAddress = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.GetAddressFromPointer)) ?? throw new InvalidOperationException("The GetAddressFromPointer field was not found."); diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs index 56f29eea..6d800d08 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ExportedFunctionsPrefixTests.cs @@ -91,13 +91,13 @@ public void Prefix_stops_before_the_pointer_to_pointer_hook_suffix() public void Historically_null_and_conflicting_slots_stay_opaque() { FieldInfo fixMemory = typeof(ExportedFunctionsPrefix).GetField(nameof(ExportedFunctionsPrefix.FixMemory), - BindingFlags.Instance | BindingFlags.Public) - ?? throw new InvalidOperationException("The FixMemory field was not found."); + BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException("The FixMemory field was not found."); FieldInfo getAddressFromPointer = typeof(ExportedFunctionsPrefix).GetField( - nameof(ExportedFunctionsPrefix.GetAddressFromPointer), - BindingFlags.Instance | BindingFlags.Public) - ?? throw new InvalidOperationException( - "The GetAddressFromPointer field was not found."); + nameof(ExportedFunctionsPrefix.GetAddressFromPointer), + BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException( + "The GetAddressFromPointer field was not found."); Type fixMemoryType = fixMemory.GetModifiedFieldType().UnderlyingSystemType; Type getAddressFromPointerType = getAddressFromPointer.GetModifiedFieldType().UnderlyingSystemType; diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs index 59e14150..68b11484 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs @@ -429,12 +429,12 @@ private static void AddViolations(List violations, LuaDirectApiP foreach (SyntaxNode node in root.DescendantNodes()) { if (node is not InvocationExpressionSyntax invocation || - !TryGetLuaApiMemberName(invocation, aliases, hasStaticLuaApiImport, hasLuaApiNamespaceImport, - shadowedNames, out string memberName) || - !policy.TryGet(memberName, out LuaDirectApiPolicyEntry entry) || - !entry.RequiresBridge || - entry.AllowedDirectly || - IsConditionallyAllowed(entry, invocation, path)) + !TryGetLuaApiMemberName(invocation, aliases, hasStaticLuaApiImport, hasLuaApiNamespaceImport, + shadowedNames, out string memberName) || + !policy.TryGet(memberName, out LuaDirectApiPolicyEntry entry) || + !entry.RequiresBridge || + entry.AllowedDirectly || + IsConditionallyAllowed(entry, invocation, path)) { continue; } @@ -450,7 +450,7 @@ private static HashSet CollectLuaApiAliases(CompilationUnitSyntax root) foreach (SyntaxNode node in root.DescendantNodesAndSelf()) { if (node is not UsingDirectiveSyntax directive || directive.Alias is null || directive.Name is null || - !IsExactLuaApiTypeName(directive.Name.ToString())) + !IsExactLuaApiTypeName(directive.Name.ToString())) { continue; } @@ -466,8 +466,8 @@ private static bool HasStaticLuaApiImport(CompilationUnitSyntax root) foreach (SyntaxNode node in root.DescendantNodesAndSelf()) { if (node is UsingDirectiveSyntax { Name: not null } directive && - directive.StaticKeyword.RawKind != 0 && - IsExactLuaApiTypeName(directive.Name.ToString())) + directive.StaticKeyword.RawKind != 0 && + IsExactLuaApiTypeName(directive.Name.ToString())) { return true; } @@ -481,8 +481,8 @@ private static bool HasLuaApiNamespaceImport(CompilationUnitSyntax root) foreach (SyntaxNode node in root.DescendantNodesAndSelf()) { if (node is UsingDirectiveSyntax { Alias: null, Name: not null } directive && - directive.StaticKeyword.RawKind == 0 && - IsLuaApiNamespaceName(directive.Name.ToString())) + directive.StaticKeyword.RawKind == 0 && + IsLuaApiNamespaceName(directive.Name.ToString())) { return true; } @@ -527,14 +527,14 @@ private static bool TryGetLuaApiMemberName(InvocationExpressionSyntax invocation switch (invocation.Expression) { case IdentifierNameSyntax identifier when hasStaticLuaApiImport && - !shadowedNames.Contains(identifier.Identifier.ValueText): + !shadowedNames.Contains(identifier.Identifier.ValueText): memberName = identifier.Identifier.ValueText; return true; case MemberAccessExpressionSyntax { Name: IdentifierNameSyntax name } memberAccess: string typeName = memberAccess.Expression.ToString(); if (IsExactLuaApiTypeName(typeName) || aliases.Contains(typeName) || - (hasLuaApiNamespaceImport && string.Equals(typeName, "LuaApi", StringComparison.Ordinal))) + (hasLuaApiNamespaceImport && string.Equals(typeName, "LuaApi", StringComparison.Ordinal))) { memberName = name.Identifier.ValueText; return true; @@ -551,15 +551,15 @@ private static bool IsConditionallyAllowed(LuaDirectApiPolicyEntry entry, Invoca string path) { if (!entry.ConditionalDirectUse || !entry.ConditionalAllowed || - !string.Equals(entry.MemberName, "lua_pushcclosure", StringComparison.Ordinal) || - !string.Equals(path, LightCFunctionFastPathSourcePath, StringComparison.Ordinal)) + !string.Equals(entry.MemberName, "lua_pushcclosure", StringComparison.Ordinal) || + !string.Equals(path, LightCFunctionFastPathSourcePath, StringComparison.Ordinal)) { return false; } SeparatedSyntaxList arguments = invocation.ArgumentList.Arguments; if (arguments.Count != 3 || !IsIdentifier(arguments[0].Expression, "Pointer") || - !IsIntegerZero(arguments[2].Expression)) + !IsIntegerZero(arguments[2].Expression)) { return false; } @@ -567,8 +567,8 @@ private static bool IsConditionallyAllowed(LuaDirectApiPolicyEntry entry, Invoca ExpressionStatementSyntax? pushStatement = invocation.FirstAncestorOrSelf(); MethodDeclarationSyntax? method = invocation.FirstAncestorOrSelf(); if (pushStatement is null || method is null || - !string.Equals(method.Identifier.ValueText, "PushUncheckedFunction", StringComparison.Ordinal) || - method.Body is null) + !string.Equals(method.Identifier.ValueText, "PushUncheckedFunction", StringComparison.Ordinal) || + method.Body is null) { return false; } @@ -590,20 +590,20 @@ private static bool IsConditionallyAllowed(LuaDirectApiPolicyEntry entry, Invoca private static bool IsImmediateOneSlotCheckStackGuard(StatementSyntax statement) { if (statement is not IfStatementSyntax condition || - !ContainsOnlyThrow(condition.Statement) || - condition.Condition is not BinaryExpressionSyntax equals || - !equals.IsKind(SyntaxKind.EqualsExpression) || - !IsIntegerZero(equals.Right)) + !ContainsOnlyThrow(condition.Statement) || + condition.Condition is not BinaryExpressionSyntax equals || + !equals.IsKind(SyntaxKind.EqualsExpression) || + !IsIntegerZero(equals.Right)) { return false; } if (equals.Left is not InvocationExpressionSyntax - { - Expression: IdentifierNameSyntax { Identifier.ValueText: "lua_checkstack" }, - ArgumentList.Arguments: var arguments - } || arguments.Count != 2 || !IsIdentifier(arguments[0].Expression, "Pointer") || - !IsIntegerOne(arguments[1].Expression)) + { + Expression: IdentifierNameSyntax { Identifier.ValueText: "lua_checkstack" }, + ArgumentList.Arguments: var arguments + } || arguments.Count != 2 || !IsIdentifier(arguments[0].Expression, "Pointer") || + !IsIntegerOne(arguments[1].Expression)) { return false; } @@ -614,7 +614,7 @@ condition.Condition is not BinaryExpressionSyntax equals || private static bool ContainsOnlyThrow(StatementSyntax statement) { return statement is ThrowStatementSyntax || - (statement is BlockSyntax { Statements.Count: 1 } block && block.Statements[0] is ThrowStatementSyntax); + (statement is BlockSyntax { Statements.Count: 1 } block && block.Statements[0] is ThrowStatementSyntax); } private static bool IsIntegerZero(ExpressionSyntax expression) @@ -662,7 +662,7 @@ private static bool IsIntegerOne(ExpressionSyntax expression) private static bool IsIdentifier(ExpressionSyntax expression, string identifier) { return expression is IdentifierNameSyntax name && - string.Equals(name.Identifier.ValueText, identifier, StringComparison.Ordinal); + string.Equals(name.Identifier.ValueText, identifier, StringComparison.Ordinal); } private static string[] ReadProvenance(JsonElement entry) @@ -691,7 +691,7 @@ private static string GetRepositoryPath(string sourcePath) private static bool IsGeneratedPath(string repositoryPath) { return repositoryPath.Contains("/bin/", StringComparison.Ordinal) || - repositoryPath.Contains("/obj/", StringComparison.Ordinal); + repositoryPath.Contains("/obj/", StringComparison.Ordinal); } private static bool IsExactLuaApiTypeName(string typeName) diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs index e490baa6..3a28a03b 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Diagnostics/DiagnosticCatalogTests.cs @@ -117,21 +117,23 @@ public void Release_tracking_rows_are_unique_across_shipped_and_unshipped_files( ]; foreach (string trackingFile in trackingFiles) - foreach (string line in File.ReadLines(trackingFile)) { - string[] cells = line.Split('|'); - if (cells.Length < 3) + foreach (string line in File.ReadLines(trackingFile)) { - continue; + string[] cells = line.Split('|'); + if (cells.Length < 3) + { + continue; + } + + string id = cells[0].Trim(); + if (!id.StartsWith("CESDK", StringComparison.Ordinal)) + { + continue; + } + + Assert.True(ids.Add(id), $"Release tracking contains duplicate diagnostic id '{id}'."); } - - string id = cells[0].Trim(); - if (!id.StartsWith("CESDK", StringComparison.Ordinal)) - { - continue; - } - - Assert.True(ids.Add(id), $"Release tracking contains duplicate diagnostic id '{id}'."); } Assert.Equal(SortedIds(AllDescriptors()), ids.Order(StringComparer.Ordinal), StringComparer.Ordinal); @@ -145,9 +147,9 @@ private static bool HasRow(string tracking, DiagnosticDescriptor descriptor) { string[] cells = line.Split('|'); if (cells.Length >= 3 - && string.Equals(cells[0].Trim(), descriptor.Id, StringComparison.Ordinal) - && string.Equals(cells[1].Trim(), descriptor.Category, StringComparison.Ordinal) - && string.Equals(cells[2].Trim(), descriptor.DefaultSeverity.ToString(), StringComparison.Ordinal)) + && string.Equals(cells[0].Trim(), descriptor.Id, StringComparison.Ordinal) + && string.Equals(cells[1].Trim(), descriptor.Category, StringComparison.Ordinal) + && string.Equals(cells[2].Trim(), descriptor.DefaultSeverity.ToString(), StringComparison.Ordinal)) { return true; } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs index bffcf14c..f1a99c66 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Generation/LuaBindingAnalyzerTests.cs @@ -58,32 +58,32 @@ public sealed class LuaBindingAnalyzerTests { { "valid function", ShapeUsings + - "public static partial class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", + "public static partial class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", true }, { "instance method", ShapeUsings + - "public partial class Functions { [LuaFunction(\"add\")] public long Add(long a, long b) => a + b; }", + "public partial class Functions { [LuaFunction(\"add\")] public long Add(long a, long b) => a + b; }", false }, { "not partial container", ShapeUsings + - "public static class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", + "public static class Functions { [LuaFunction(\"add\")] public static long Add(long a, long b) => a + b; }", false }, { "invalid lua name", ShapeUsings + - "public static partial class Functions { [LuaFunction(\"end\")] public static long Add(long a, long b) => a + b; }", + "public static partial class Functions { [LuaFunction(\"end\")] public static long Add(long a, long b) => a + b; }", false }, { "valid global try form", ShapeUsings + - "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial bool TryReadInt32(nuint address, out int value); }", + "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial bool TryReadInt32(nuint address, out int value); }", true }, { "global try form returning int instead of bool", ShapeUsings + - "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial int TryReadInt32(nuint address, out int value); }", + "public static partial class Bindings { [LuaGlobal(\"readInteger\")] public static partial int TryReadInt32(nuint address, out int value); }", false }, { @@ -421,10 +421,10 @@ public static class Functions static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaBindingContainingType, StringComparison.Ordinal)); Assert.Contains(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("reserved word", StringComparison.Ordinal)); + d.GetMessage(CultureInfo.InvariantCulture).Contains("reserved word", StringComparison.Ordinal)); Assert.Contains(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("default value", StringComparison.Ordinal)); + d.GetMessage(CultureInfo.InvariantCulture).Contains("default value", StringComparison.Ordinal)); } [Fact] @@ -462,11 +462,11 @@ public static partial class Globals ImmutableArray diagnostics = await GetDiagnosticsAsync(compilation); Assert.Contains(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaFunction, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture) - .Contains("parameter type", StringComparison.Ordinal)); + d.GetMessage(CultureInfo.InvariantCulture) + .Contains("parameter type", StringComparison.Ordinal)); Assert.Contains(diagnostics, static d => string.Equals(d.Id, DiagnosticIds.InvalidLuaGlobal, StringComparison.Ordinal) && - d.GetMessage(CultureInfo.InvariantCulture).Contains("argument type", StringComparison.Ordinal)); + d.GetMessage(CultureInfo.InvariantCulture).Contains("argument type", StringComparison.Ordinal)); } [Theory] diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs index c0127865..ab0bfb7e 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/CodeFixVerifier.cs @@ -37,7 +37,8 @@ public static Task VerifyAsync( { CheatEngineSdkCodeFixTest test = new() { - CodeActionEquivalenceKey = equivalenceKey, NumberOfFixAllIterations = fixAllIterations + CodeActionEquivalenceKey = equivalenceKey, + NumberOfFixAllIterations = fixAllIterations }; // The default drops fixable ids from the markup of the fixed state, assuming a fix always removes them all. diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs index d681851c..503606c6 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -76,8 +76,8 @@ public static ImmutableArray FromTargetingPack() { string candidate = Path.Combine(pack, "ref", TargetFramework); if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out Version? version) - && (bestVersion is null || version > bestVersion)) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) { best = candidate; bestVersion = version; diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs index f3392bd5..d0653902 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/LocalFrameworkReferencesTests.cs @@ -42,7 +42,7 @@ public void References_are_existing_local_files_and_include_the_core_facade() foreach (MetadataReference reference in LocalFrameworkReferences.References) { string path = Assert.IsType(reference, false).FilePath - ?? throw new InvalidOperationException("A framework reference without a file path."); + ?? throw new InvalidOperationException("A framework reference without a file path."); Assert.True(File.Exists(path), path); hasSystemRuntime |= string.Equals(Path.GetFileName(path), "System.Runtime.dll", StringComparison.OrdinalIgnoreCase); diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs index 7b8fe539..7262e163 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Infrastructure/RepositoryLayout.cs @@ -20,8 +20,8 @@ public static string PathOf(string relativePath) private static string FindRoot() { for (DirectoryInfo? directory = new(AppContext.BaseDirectory); - directory is not null; - directory = directory.Parent) + directory is not null; + directory = directory.Parent) { if (File.Exists(Path.Combine(directory.FullName, SolutionFileName))) { diff --git a/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs index b5d0e195..f7068a7c 100644 --- a/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs @@ -64,7 +64,7 @@ public void Setup() if (!defined.IsOk) { throw new InvalidOperationException("Defining cheatengine_sdk_bench_loop failed: " + - LuaError.FromStack(_l, defined)); + LuaError.FromStack(_l, defined)); } } diff --git a/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs index dc3560c6..a2944adf 100644 --- a/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/EngineApiIncrementalBenchmarks.cs @@ -183,7 +183,7 @@ private BenchmarkAdditionalText Second() private BenchmarkAdditionalText EditedFirst() { return _editedFirstSpec ?? - throw new InvalidOperationException("Benchmark setup did not create the edited spec."); + throw new InvalidOperationException("Benchmark setup did not create the edited spec."); } private GeneratorDriver WarmDriver() diff --git a/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs index 394c262d..b239430e 100644 --- a/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/GlobalCallBenchmarks.cs @@ -37,7 +37,7 @@ public void Setup() if (!defined.IsOk) { throw new InvalidOperationException("Defining cheatengine_sdk_bench_add failed: " + - LuaError.FromStack(l, defined)); + LuaError.FromStack(l, defined)); } } diff --git a/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs index 815c97b1..d3c4fedc 100644 --- a/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/MemoryScalarBenchmarks.cs @@ -81,7 +81,7 @@ public void Setup() if (!defined.IsOk) { throw new InvalidOperationException("Defining scalar-memory fixture globals failed: " + - LuaError.FromStack(state, defined)); + LuaError.FromStack(state, defined)); } if (!TargetMemory.TryWriteInt32(Address32, -42, out _)) @@ -95,15 +95,15 @@ public void Setup() } if (!HostMemory.TryWriteInt32(HostAddress32, -42, out _) || - !HostMemory.TryWriteInt64(HostAddress64, 0x1_0000_0000L, out _)) + !HostMemory.TryWriteInt64(HostAddress64, 0x1_0000_0000L, out _)) { throw new InvalidOperationException("Warming local scalar writes failed."); } if (!TargetMemory.TryReadInt32(Address32, out _, out _) || - !TargetMemory.TryReadInt64(Address64, out _, out _) || - !HostMemory.TryReadInt32(HostAddress32, out _, out _) || - !HostMemory.TryReadInt64(HostAddress64, out _, out _)) + !TargetMemory.TryReadInt64(Address64, out _, out _) || + !HostMemory.TryReadInt32(HostAddress32, out _, out _) || + !HostMemory.TryReadInt64(HostAddress64, out _, out _)) { throw new InvalidOperationException("Warming scalar reads failed."); } diff --git a/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs b/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs index 71d2a5f9..352cea17 100644 --- a/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs +++ b/tests/CheatEngine.SDK.Benchmarks/Support/FakeHostRuntime.cs @@ -72,7 +72,7 @@ private static void InstallMetatable(LuaState state) if (!status.IsOk) { throw new InvalidOperationException("Installing the fake host metatable failed: " + - LuaError.FromStack(state, status)); + LuaError.FromStack(state, status)); } state.RawSetPointer(LuaState.RegistryIndex, s_metatableKey); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs index 506d047b..24596ab3 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/AllocatedRegionTests.cs @@ -14,7 +14,10 @@ public sealed class AllocatedRegionTests [Fact] public void Dispose_releases_the_original_target_address_and_size_exactly_once() { - AllocationOperationsFake operations = new() { AllocatedAddress = new Address(0x7FF6_3000_0000) }; + AllocationOperationsFake operations = new() + { + AllocatedAddress = new Address(0x7FF6_3000_0000) + }; AllocatedRegion region = Allocate(operations, 12288); region.Dispose(); @@ -31,7 +34,10 @@ public void Dispose_releases_the_original_target_address_and_size_exactly_once() [Fact] public void Dispose_when_CE_reports_failure_is_no_throw_and_consumes_ownership() { - AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocationOperationsFake operations = new() + { + DeallocationResult = false + }; AllocatedRegion region = Allocate(operations, 4096); region.Dispose(); @@ -44,7 +50,10 @@ public void Dispose_when_CE_reports_failure_is_no_throw_and_consumes_ownership() [Fact] public void Release_when_CE_reports_failure_throws_the_expected_failure_and_never_retries() { - AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocationOperationsFake operations = new() + { + DeallocationResult = false + }; AllocatedRegion region = Allocate(operations, 4096); EngineOperationFailedException exception = Assert.Throws(region.Release); @@ -60,7 +69,10 @@ public void Release_when_CE_reports_failure_throws_the_expected_failure_and_neve public void Release_when_the_protected_lua_call_fails_preserves_the_failure_and_consumes_ownership() { EngineLuaException failure = new("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); EngineLuaException thrown = Assert.Throws(region.Release); @@ -75,7 +87,10 @@ public void Release_when_the_protected_lua_call_fails_preserves_the_failure_and_ public void Release_when_the_required_global_is_unavailable_preserves_the_distinct_failure() { EngineGlobalUnavailableException failure = new("TargetMemoryDeallocate"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); EngineGlobalUnavailableException thrown = Assert.Throws(region.Release); @@ -91,7 +106,10 @@ public void Release_when_the_binding_contract_fails_preserves_the_failure_and_co { EngineBindingException failure = new("TargetMemoryDeallocate", "the generated binding returned an incompatible result"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); EngineBindingException thrown = Assert.Throws(region.Release); @@ -106,7 +124,10 @@ public void Release_when_the_result_cannot_be_marshalled_preserves_the_failure_a { EngineMarshallingException failure = new("TargetMemoryDeallocate", EngineMarshallingDirection.Result, "a boolean", "a table"); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); EngineMarshallingException thrown = Assert.Throws(region.Release); @@ -137,7 +158,10 @@ public void Dispose_when_the_binding_fails_is_no_throw_and_consumes_ownership() public void Dispose_when_an_EngineException_occurs_preserves_the_structured_failure_kind_and_consumes_ownership() { EngineException failure = new EngineLuaException("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = failure }; + AllocationOperationsFake operations = new() + { + DeallocationException = failure + }; AllocatedRegion region = Allocate(operations, 4096); region.Dispose(); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs index 40c41ce9..01f17362 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/LuaTargetMemoryAllocationOperationsTests.cs @@ -424,7 +424,7 @@ function deAlloc(address, size) private static void InstallCurrentTarget(LuaState state) { EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + - Environment.ProcessId + " end")); + Environment.ProcessId + " end")); } private static void AssertLuaInteger(LuaState state, string name, long expected) diff --git a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs index 8464908d..3db516cb 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Allocation/TargetMemoryAllocatorTests.cs @@ -38,7 +38,10 @@ public void Allocate_when_owner_publication_fails_compensates_once_and_exposes_t [Fact] public void Allocate_when_owner_publication_and_compensation_fail_reports_an_unconfirmed_effect_without_retrying() { - AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocationOperationsFake operations = new() + { + DeallocationResult = false + }; TargetMemoryAllocator allocator = new(operations); EngineResourceHandoffException exception = Assert.Throws(() => @@ -57,7 +60,10 @@ public void Allocate_when_owner_publication_and_compensation_raise_keeps_the_primary_cause_and_marks_the_effect_unknown() { EngineLuaException cleanupFailure = new("TargetMemoryDeallocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { DeallocationException = cleanupFailure }; + AllocationOperationsFake operations = new() + { + DeallocationException = cleanupFailure + }; TargetMemoryAllocator allocator = new(operations); InvalidOperationException cause = new("injected region publication failure"); @@ -95,7 +101,10 @@ public void Allocate_when_owner_publication_observes_a_replacement_target_refuse [Fact] public void Allocate_on_success_returns_an_owned_region_and_forwards_the_full_request() { - AllocationOperationsFake operations = new() { AllocatedAddress = new Address(0x7FF6_1234_0000) }; + AllocationOperationsFake operations = new() + { + AllocatedAddress = new Address(0x7FF6_1234_0000) + }; TargetMemoryAllocator allocator = new(operations); TargetAllocationRequest request = new(new TargetAllocationSize(8192), new Address(0x7FF6_1200_0000), MemoryProtection.ExecuteReadWrite); @@ -111,7 +120,11 @@ public void Allocate_on_success_returns_an_owned_region_and_forwards_the_full_re [Fact] public void Allocate_when_CE_reports_expected_failure_throws_the_stable_expected_failure() { - AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = Address.Zero }; + AllocationOperationsFake operations = new() + { + AllocationResult = false, + AllocatedAddress = Address.Zero + }; TargetMemoryAllocator allocator = new(operations); EngineOperationFailedException exception = Assert.Throws(() => @@ -125,7 +138,10 @@ public void Allocate_when_CE_reports_expected_failure_throws_the_stable_expected [Fact] public void Allocate_when_CE_reports_success_without_an_address_preserves_the_unknown_effect_diagnostic() { - AllocationOperationsFake operations = new() { AllocatedAddress = Address.Zero }; + AllocationOperationsFake operations = new() + { + AllocatedAddress = Address.Zero + }; TargetMemoryAllocator allocator = new(operations); EngineResourceHandoffException exception = Assert.Throws(() => @@ -142,7 +158,11 @@ public void Allocate_when_CE_reports_success_without_an_address_preserves_the_un public void Allocate_when_CE_reports_failure_with_an_address_throws_marshalling() { AllocationOperationsFake operations = - new() { AllocationResult = false, AllocatedAddress = new Address(0x1234) }; + new() + { + AllocationResult = false, + AllocatedAddress = new Address(0x1234) + }; TargetMemoryAllocator allocator = new(operations); EngineMarshallingException exception = Assert.Throws(() => @@ -157,7 +177,10 @@ public void Allocate_when_the_binding_fails_preserves_the_binding_exception() { EngineBindingException failure = new("TargetMemoryAllocate", "the generated binding returned an incompatible result"); - AllocationOperationsFake operations = new() { AllocationException = failure }; + AllocationOperationsFake operations = new() + { + AllocationException = failure + }; TargetMemoryAllocator allocator = new(operations); EngineBindingException thrown = Assert.Throws(() => @@ -170,7 +193,10 @@ public void Allocate_when_the_binding_fails_preserves_the_binding_exception() public void Allocate_when_the_required_global_is_unavailable_preserves_that_distinct_failure() { EngineGlobalUnavailableException failure = new("TargetMemoryAllocate"); - AllocationOperationsFake operations = new() { AllocationException = failure }; + AllocationOperationsFake operations = new() + { + AllocationException = failure + }; TargetMemoryAllocator allocator = new(operations); EngineGlobalUnavailableException thrown = Assert.Throws(() => @@ -184,7 +210,10 @@ public void Allocate_when_the_required_global_is_unavailable_preserves_that_dist public void Allocate_when_the_protected_lua_call_fails_preserves_the_EngineLuaException() { EngineLuaException failure = new("TargetMemoryAllocate", LuaStatus.RuntimeError); - AllocationOperationsFake operations = new() { AllocationException = failure }; + AllocationOperationsFake operations = new() + { + AllocationException = failure + }; TargetMemoryAllocator allocator = new(operations); EngineLuaException thrown = Assert.Throws(() => @@ -198,7 +227,10 @@ public void AllocateWithOutcome_adapts_the_legacy_bool_seam_without_parsing_exce { EngineLuaException failure = new("TargetMemoryAllocate", LuaStatus.SyntaxError, "A deliberately irrelevant localized message."); - AllocationOperationsFake operations = new() { AllocationException = failure }; + AllocationOperationsFake operations = new() + { + AllocationException = failure + }; TargetMemoryAllocator allocator = new(operations); TargetMemoryAllocationOutcome outcome = @@ -214,7 +246,11 @@ public void AllocateWithOutcome_adapts_the_legacy_bool_seam_without_parsing_exce [Fact] public void AllocateWithOutcome_adapts_legacy_expected_failure_without_creating_an_owner() { - AllocationOperationsFake operations = new() { AllocationResult = false, AllocatedAddress = Address.Zero }; + AllocationOperationsFake operations = new() + { + AllocationResult = false, + AllocatedAddress = Address.Zero + }; TargetMemoryAllocator allocator = new(operations); TargetMemoryAllocationOutcome outcome = @@ -287,7 +323,10 @@ public void Allocate_with_a_target_bound_boundary_failure_throws_its_stable_publ [Fact] public void ReleaseWithOutcome_adapts_the_legacy_bool_seam_and_consumes_ownership() { - AllocationOperationsFake operations = new() { DeallocationResult = false }; + AllocationOperationsFake operations = new() + { + DeallocationResult = false + }; TargetMemoryAllocator allocator = new(operations); AllocatedRegion region = allocator.Allocate(new TargetAllocationRequest(new TargetAllocationSize(4096))); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs index b3be42d0..2bcc1daf 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/AutoAssemblerPatcherTests.cs @@ -503,8 +503,8 @@ private static LuaRef FailDisableInfoTracking(LuaState _) private static void InstallAutoAssembler(LuaState state) { EngineTest.Run(state, Encoding.UTF8.GetBytes("auto_assembler_target_process_id = " + - Environment.ProcessId + - "\nfunction getOpenedProcessID() return auto_assembler_target_process_id end")); + Environment.ProcessId + + "\nfunction getOpenedProcessID() return auto_assembler_target_process_id end")); EngineTest.Run(state, """ auto_assembler_apply_count = 0 auto_assembler_disable_count = 0 diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs index 9985a1d8..df4752eb 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs @@ -245,8 +245,14 @@ public void ResolveAddress_reports_an_unavailable_global_without_entering_lua() [Fact] public void AddressResolutionOptions_supports_init_and_with() { - AddressResolutionOptions options = new() { Shallow = true }; - AddressResolutionOptions updated = options with { Shallow = false }; + AddressResolutionOptions options = new() + { + Shallow = true + }; + AddressResolutionOptions updated = options with + { + Shallow = false + }; Assert.True(options.Shallow); Assert.False(updated.Shallow); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs index 7e0dc2a5..caf9ef7f 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs @@ -293,7 +293,7 @@ public void RuntimeProcess_capability_identifiers_are_stable_and_distinct() private static void InstallCurrentProcessGlobals(LuaState state, int processId, bool is64Bit) { string source = "function getOpenedProcessID() return " + processId + " end\n" + - "function targetIs64Bit() return " + LuaBoolean(is64Bit) + " end"; + "function targetIs64Bit() return " + LuaBoolean(is64Bit) + " end"; EngineTest.Run(state, Encoding.UTF8.GetBytes(source)); } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs index c419e50b..d2bbbc52 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs @@ -362,8 +362,8 @@ function getOpenedProcessID() private static void InstallCurrentTarget(LuaState state) { EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + - Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + - " end")); + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + + " end")); } private static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEObject value) diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs index 630bfdd5..22a4985f 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs @@ -837,8 +837,8 @@ private static MemoryScanSession CreateSession(LuaState state, bool firstScanRai private static void InstallCurrentTarget(LuaState state) { EngineTest.Run(state, Encoding.UTF8.GetBytes("opened_process_id = " + - Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + - "; function getOpenedProcessID() return opened_process_id end")); + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + + "; function getOpenedProcessID() return opened_process_id end")); } private static string ScanInitializer(bool firstScanRaises, bool waitRaises) @@ -860,14 +860,14 @@ private static string FoundListInitializer(bool invalidAddress = false, string r string firstAddress = invalidAddress ? "'not-an-address'" : "'00001234'"; string destroyFailure = destroyRaises ? "; error('found-list destroy rejected')" : string.Empty; return "o.props.initialize = function() table.insert(trace, 'list.initialize') end\n" + - "o.props.deinitialize = function() table.insert(trace, 'list.deinitialize') end\n" + - "o.props.Count = " + resultCountLiteral + "\n" + - "o.props.getCount = function() table.insert(trace, 'results.getCount'); return o.props.Count end\n" + - "o.props.getAddress = function(index) table.insert(trace, 'results.getAddress:' .. index); if index == 0 then return " + - firstAddress + " end; return 'FFFFFFFFFFFFFFFF' end\n" + - "o.props.getValue = function(index) table.insert(trace, 'results.getValue:' .. index); return '100' end\n" + - "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'list.destroy')" + - destroyFailure + " end end"; + "o.props.deinitialize = function() table.insert(trace, 'list.deinitialize') end\n" + + "o.props.Count = " + resultCountLiteral + "\n" + + "o.props.getCount = function() table.insert(trace, 'results.getCount'); return o.props.Count end\n" + + "o.props.getAddress = function(index) table.insert(trace, 'results.getAddress:' .. index); if index == 0 then return " + + firstAddress + " end; return 'FFFFFFFFFFFFFFFF' end\n" + + "o.props.getValue = function(index) table.insert(trace, 'results.getValue:' .. index); return '100' end\n" + + "o.getters.destroy = function(o) return function() o.destroyed = true; table.insert(trace, 'list.destroy')" + + destroyFailure + " end end"; } private static string ReadTrace(LuaState state) diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs index ffc733b5..0d25ba1b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/EngineTest.cs @@ -14,7 +14,7 @@ internal static class EngineTest /// Debug.Assert guards exist. Tests of a Debug-only guard skip in Release. /// #if DEBUG - public const bool IsDebugBuild = true; + public const bool IsDebugBuild = true; #else public const bool IsDebugBuild = false; #endif diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs index 8e752300..97fa86c1 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs @@ -327,15 +327,15 @@ internal PCallProbe(Action? afterWaitTillDone, Action? afterGetAddress = null) } FieldInfo tableField = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic) - ?? throw new InvalidOperationException( - "The Lua API table was not available for probing."); + ?? throw new InvalidOperationException( + "The Lua API table was not available for probing."); _table = tableField.GetValue(null) - ?? throw new InvalidOperationException("The Lua API table was not initialized for probing."); + ?? throw new InvalidOperationException("The Lua API table was not initialized for probing."); _pcallField = _table.GetType().GetField("lua_pcallk", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException( - "The Lua protected-call slot was not available for probing."); + ?? throw new InvalidOperationException( + "The Lua protected-call slot was not available for probing."); s_forwardedPCall = (nint) (_pcallField.GetValue(_table) - ?? throw new InvalidOperationException("The Lua protected-call slot was null.")); + ?? throw new InvalidOperationException("The Lua protected-call slot was null.")); _pcallField.SetValue(_table, (nint) (delegate* unmanaged[Cdecl]) &ObservePCall); tableField.SetValue(null, _table); @@ -411,8 +411,8 @@ private static int ObservePCall(lua_State* state, int argumentCount, int resultC { PCallProbe? probe = s_activePCallProbe; if (probe is not null && - (nint) lua_tocfunction(state, -argumentCount - 1) == - (nint) (delegate* unmanaged[Cdecl]) &WaitTillDone) + (nint) lua_tocfunction(state, -argumentCount - 1) == + (nint) (delegate* unmanaged[Cdecl]) &WaitTillDone) { probe.Observe(argumentCount, resultCount); } diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs b/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs index e3548c17..03cfc4aa 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Support/RecordingPlugin.cs @@ -250,15 +250,15 @@ protected internal override void OnEnable() if (CreateCallbacksInOnEnable) { if (!LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), - out LuaCallback? first).IsOk - || first is null) + out LuaCallback? first).IsOk + || first is null) { throw new InvalidOperationException("first callback creation failed"); } if (!LuaCallback.TryCreate(L, new LuaNativeFunction(&NoOpThunk), new object(), - out LuaCallback? second).IsOk - || second is null) + out LuaCallback? second).IsOk + || second is null) { throw new InvalidOperationException("second callback creation failed"); } diff --git a/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs b/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs index f1d18bdc..2b33a527 100644 --- a/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs +++ b/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs @@ -83,7 +83,7 @@ private static void WriteFileIdentityFields(Utf8JsonWriter writer, string? path, writer.WriteString("machine", reader.PEHeaders.CoffHeader.Machine.ToString()); } catch (Exception exception) when (exception is BadImageFormatException or IOException - or UnauthorizedAccessException) + or UnauthorizedAccessException) { writer.WriteString("machine", "unavailable: " + exception.GetType().Name); } @@ -130,7 +130,7 @@ internal static string ObserveFileVersion(string path, Func get return getFileVersion(path) ?? "not-present"; } catch (Exception exception) when (exception is ArgumentException or Win32Exception - or IOException or UnauthorizedAccessException) + or IOException or UnauthorizedAccessException) { return "unavailable: " + exception.GetType().Name; } @@ -150,7 +150,7 @@ internal static string ObserveFileVersion(string path, Func get } } catch (Exception exception) when (exception is InvalidOperationException or NotSupportedException - or Win32Exception) + or Win32Exception) { HostLog.Write(HostLogLevel.Warning, "CE 7.7 host-profile probe could not enumerate loaded modules.", exception); diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs index d10b460b..e85be096 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs @@ -189,17 +189,17 @@ private static bool TryReadManifest(string path, out AuthorizationManifest manif using JsonDocument document = JsonDocument.Parse(stream); JsonElement root = document.RootElement; if (root.ValueKind != JsonValueKind.Object || !TryString(root, "schema", out string schema) || - !string.Equals(schema, "ce77-live-probe-v1", StringComparison.Ordinal) || - !TryString(root, "acknowledgement", out string acknowledgement) || - !TryString(root, "hostSha256", out string hostSha256) || - !TryString(root, "targetSha256", out string targetSha256) || - !TryString(root, "expiresUtc", out string expiresText) || - !root.TryGetProperty("targetProcessId", out JsonElement pid) || - !pid.TryGetInt32(out int targetProcessId) || - !root.TryGetProperty("disposable", out JsonElement disposable) || - disposable.ValueKind is not JsonValueKind.True and not JsonValueKind.False || - !DateTimeOffset.TryParse(expiresText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, - out DateTimeOffset expiresUtc)) + !string.Equals(schema, "ce77-live-probe-v1", StringComparison.Ordinal) || + !TryString(root, "acknowledgement", out string acknowledgement) || + !TryString(root, "hostSha256", out string hostSha256) || + !TryString(root, "targetSha256", out string targetSha256) || + !TryString(root, "expiresUtc", out string expiresText) || + !root.TryGetProperty("targetProcessId", out JsonElement pid) || + !pid.TryGetInt32(out int targetProcessId) || + !root.TryGetProperty("disposable", out JsonElement disposable) || + disposable.ValueKind is not JsonValueKind.True and not JsonValueKind.False || + !DateTimeOffset.TryParse(expiresText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, + out DateTimeOffset expiresUtc)) { failure = "The authorization manifest is missing a required ce77-live-probe-v1 field."; return false; @@ -210,7 +210,7 @@ disposable.ValueKind is not JsonValueKind.True and not JsonValueKind.False || return true; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException - or ArgumentException) + or ArgumentException) { failure = exception.GetType().Name + ": " + exception.Message; return false; @@ -246,7 +246,7 @@ private static bool TryGetProcessImage(int processId, out string path, out strin return true; } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException - or NotSupportedException or Win32Exception) + or NotSupportedException or Win32Exception) { failure = exception.GetType().Name + ": " + exception.Message; return false; @@ -275,7 +275,7 @@ private static bool IsAmd64Pe(string path, out string failure) return false; } catch (Exception exception) when (exception is BadImageFormatException or IOException - or UnauthorizedAccessException) + or UnauthorizedAccessException) { failure = exception.GetType().Name + ": " + exception.Message; return false; diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs index fee5e7d3..036896fc 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs @@ -88,7 +88,10 @@ internal static void TryWriteTailCanaryAfterPackedRecord(nint initRecord, int bo { lock (Gate) { - s_bootstrap = s_bootstrap with { TailFailure = Describe(exception) }; + s_bootstrap = s_bootstrap with + { + TailFailure = Describe(exception) + }; } HostLog.Write(HostLogLevel.Error, "CE 7.7 live-probe tail canary failed.", exception); @@ -252,7 +255,8 @@ internal static string BeginSynchronizeProbe() Thread thread = new(RunSynchronizeProbe) { - IsBackground = true, Name = "CheatEngine.SDK CE77 synchronize probe" + IsBackground = true, + Name = "CheatEngine.SDK CE77 synchronize probe" }; thread.Start(); return "Synchronize probe started. Do not block the CE GUI thread; poll ce77_live_probe_synchronize_status()."; @@ -298,7 +302,11 @@ internal static string BeginLuaThreadProbe() // The delayed worker starts only after this Lua callback has returned its string to CE. It is still a live, // opt-in observation against CE's per-thread state contract, never a general concurrency guarantee for Lua. - Thread thread = new(RunLuaThreadProbe) { IsBackground = true, Name = "CheatEngine.SDK CE77 Lua thread probe" }; + Thread thread = new(RunLuaThreadProbe) + { + IsBackground = true, + Name = "CheatEngine.SDK CE77 Lua thread probe" + }; thread.Start(); return "Lua thread/registry probe started. Do not run other Lua code for one second; poll ce77_live_probe_lua_threads_status()."; @@ -466,21 +474,34 @@ private static void RunSynchronizeProbe() { MainThread.Invoke( static _ => throw new InvalidOperationException("CE77-live-probe expected dispatch failure."), 0); - observation = observation with { ExceptionResult = "unexpectedly returned" }; + observation = observation with + { + ExceptionResult = "unexpectedly returned" + }; } catch (InvalidOperationException exception) { - observation = observation with { ExceptionResult = "re-thrown: " + exception.Message }; + observation = observation with + { + ExceptionResult = "re-thrown: " + exception.Message + }; } } catch (Exception exception) { - observation = observation with { Completion = "failed", Failure = Describe(exception) }; + observation = observation with + { + Completion = "failed", + Failure = Describe(exception) + }; } lock (Gate) { - s_synchronize = observation with { IsRunning = false }; + s_synchronize = observation with + { + IsRunning = false + }; } } @@ -517,12 +538,19 @@ private static void RunLuaThreadProbe() } catch (Exception exception) { - observation = observation with { Completion = "failed", Failure = Describe(exception) }; + observation = observation with + { + Completion = "failed", + Failure = Describe(exception) + }; } lock (Gate) { - s_luaThread = observation with { IsRunning = false }; + s_luaThread = observation with + { + IsRunning = false + }; } } diff --git a/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs b/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs index 7261da7c..7d3ac5c8 100644 --- a/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs +++ b/tests/CheatEngine.SDK.Lua.FailureProbe/Program.cs @@ -95,9 +95,9 @@ public static int Main(string[] arguments) private static bool HasValidArguments(string[] arguments) { return arguments.Length is >= 1 and <= 2 && - (arguments.Length != 2 || - string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) || - string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal)); + (arguments.Length != 2 || + string.Equals(arguments[1], CheckStackGrowthMode, StringComparison.Ordinal) || + string.Equals(arguments[1], GeneratedFunctionAllocationMode, StringComparison.Ordinal)); } private static int RunRequestedProbe(LuaState state, nint module, string[] arguments) @@ -501,7 +501,7 @@ private static int VerifyUncheckedFunctionReservationFailure(LuaState state, Lua catch (InvalidOperationException exception) { if (!string.Equals(exception.Message, UncheckedFunctionReservationFailureMessage, - StringComparison.Ordinal)) + StringComparison.Ordinal)) { return Fail("PushUncheckedFunction returned an unstable reservation failure message"); } @@ -990,7 +990,7 @@ private static int ProbeHostObjectPusherLongJump( } if (!state.TryReadString(-1, out string? error) || - !error.Contains("bad argument #1", StringComparison.Ordinal)) + !error.Contains("bad argument #1", StringComparison.Ordinal)) { return Fail("PushHostObject did not leave the native luaL_checkinteger failure message on the stack"); } diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs index 9a5a420c..7aaf14b9 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Initialization/LuaApiBoundTableTests.cs @@ -41,7 +41,7 @@ public void Every_slot_holds_an_address_inside_the_process() object table = typeof(LuaApi).GetField("s_table", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; FieldInfo[] slots = typeof(LuaApi.Table).GetFields(BindingFlags.Instance | BindingFlags.Public | - BindingFlags.NonPublic); + BindingFlags.NonPublic); Assert.NotEmpty(slots); foreach (FieldInfo slot in slots) diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs index e37569dd..80f3f828 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Protected/LuaBridgeContractTests.cs @@ -96,7 +96,7 @@ private static bool HasDelayImports(string path) int delayImportDirectoryOffset = optionalOffset + 112 + 13 * 8; return ReadUInt32(image, delayImportDirectoryOffset) != 0 || - ReadUInt32(image, delayImportDirectoryOffset + sizeof(uint)) != 0; + ReadUInt32(image, delayImportDirectoryOffset + sizeof(uint)) != 0; } private static List ReadExportedNames(string path) diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs index 567703ed..20510f5f 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallTests.cs @@ -189,7 +189,13 @@ public void Load_pulls_the_chunk_through_a_managed_reader() fixed (byte* second = "* 7"u8) fixed (byte* name = "=pieces"u8) { - ReaderState pieces = new() { First = first, FirstSize = 9, Second = second, SecondSize = 3 }; + ReaderState pieces = new() + { + First = first, + FirstSize = 9, + Second = second, + SecondSize = 3 + }; Assert.Equal(LUA_OK, lua_load(L, &ReadPieces, &pieces, name, null)); Assert.Equal(3, pieces.Calls); diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs index 30bca320..d4aa61d4 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Support/IlReader.cs @@ -19,7 +19,7 @@ internal static class IlReader public static List<(OpCode Code, int Operand)> Read(MethodBase method) { byte[] il = method.GetMethodBody()?.GetILAsByteArray() ?? - throw new InvalidOperationException(method.Name + " has no IL body."); + throw new InvalidOperationException(method.Name + " has no IL body."); List<(OpCode Code, int Operand)> instructions = []; int offset = 0; diff --git a/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs index c2325fb5..c96aa5f8 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Allocation/ZeroAllocationTests.cs @@ -73,13 +73,13 @@ private static long ReadEachScalar(LuaState state, int top) // Exact on purpose: the round trip through the Lua stack must not change a single bit, // so the bit patterns are compared. if (!DoubleMarshaller.TryRead(state, top + 3, out double d) || - BitConverter.DoubleToInt64Bits(d) != BitConverter.DoubleToInt64Bits(2.5)) + BitConverter.DoubleToInt64Bits(d) != BitConverter.DoubleToInt64Bits(2.5)) { Fail(); } if (!SingleMarshaller.TryRead(state, top + 4, out float f) || - BitConverter.SingleToInt32Bits(f) != BitConverter.SingleToInt32Bits(1.5f)) + BitConverter.SingleToInt32Bits(f) != BitConverter.SingleToInt32Bits(1.5f)) { Fail(); } @@ -90,7 +90,7 @@ private static long ReadEachScalar(LuaState state, int top) } if (!AddressMarshaller.TryRead(state, top + 6, out UIntPtr a) || - a != unchecked((nuint) 0xFFFF_FFFF_FFFF_FFF0UL)) + a != unchecked((nuint) 0xFFFF_FFFF_FFFF_FFF0UL)) { Fail(); } diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs index 07cd4881..f0b43cb4 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs @@ -213,7 +213,10 @@ public void Two_callbacks_of_the_same_thunk_have_independent_state() using NativeLuaState state = new(); LuaState L = LuaTest.View(state); Counter a = new(); - Counter b = new() { Value = 100 }; + Counter b = new() + { + Value = 100 + }; Assert.True(LuaCallback.TryCreate(L, Thunks.Count, a, out LuaCallback? callbackA).IsOk); Assert.True(LuaCallback.TryCreate(L, Thunks.Count, b, out LuaCallback? callbackB).IsOk); Assert.True(callbackA!.TryRegister(L, "countA"u8).IsOk); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs index 9b40b874..1ea1b1f9 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaHostSubscriptionTests.cs @@ -434,24 +434,24 @@ public void Dispose() public bool Register() { if (!LuaHostSubscription.TryRegister(state, RunAdmittedCallback, - (registrationState, callback) => - { - HostCallback = callback; - return static _ => - { - }; - }, out LuaHostSubscription? firstSubscription)) + (registrationState, callback) => + { + HostCallback = callback; + return static _ => + { + }; + }, out LuaHostSubscription? firstSubscription)) { return false; } FirstSubscription = firstSubscription; return LuaHostSubscription.TryRegister(state, static () => - { - }, - (registrationState, callback) => releaseState => SecondUnregisters++, - out LuaHostSubscription? secondSubscription) - && (SecondSubscription = secondSubscription) is not null; + { + }, + (registrationState, callback) => releaseState => SecondUnregisters++, + out LuaHostSubscription? secondSubscription) + && (SecondSubscription = secondSubscription) is not null; } private void RunAdmittedCallback() diff --git a/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs b/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs index 194a1a52..9bc11a9f 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Generated/MemoryBindings.cs @@ -49,7 +49,7 @@ private static partial bool TryReadInt32Raw(nuint address, bool signed, out int LuaState L = LuaRuntime.AcquireState(); // one state acquisition per operation int top = L.Top; // explicit settop: no EH region on the success path if (!LuaGlobalFunctions.TryPush(L, s_readInteger, - "readInteger"u8)) // rawgeti on the cached ref; resolve + type-check + luaL_ref on first use + "readInteger"u8)) // rawgeti on the cached ref; resolve + type-check + luaL_ref on first use { return LuaCallSupport.Fail(L, top, out value); // cold, NoInlining: restore top, default the result } diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs b/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs index 01179740..34b6d056 100644 --- a/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness/Program.cs @@ -42,7 +42,7 @@ private static int Run(string[] arguments) } if (arguments.Length == 2 && string.Equals(arguments[0], LoadMode, StringComparison.Ordinal) && - string.Equals(arguments[1], AcknowledgeProcessResidentLoad, StringComparison.Ordinal)) + string.Equals(arguments[1], AcknowledgeProcessResidentLoad, StringComparison.Ordinal)) { LoadAndQueryNames(); return 0; diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs index 8c3ecfa1..f3e1ee87 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/DiagnosticsTests.cs @@ -100,12 +100,12 @@ public void Conflicting_member_and_cache_identities_are_diagnosed_on_both_spec_f run.AssertNoGeneratedSource(); Assert.Contains(run.GeneratorDiagnostics, static diagnostic => string.Equals(diagnostic.Id, "CESDK3002", StringComparison.Ordinal) - && diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains("member", StringComparison.Ordinal)); + && diagnostic.GetMessage(CultureInfo.InvariantCulture) + .Contains("member", StringComparison.Ordinal)); Assert.Contains(run.GeneratorDiagnostics, static diagnostic => string.Equals(diagnostic.Id, "CESDK3002", StringComparison.Ordinal) - && diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains("cache field", StringComparison.Ordinal)); + && diagnostic.GetMessage(CultureInfo.InvariantCulture) + .Contains("cache field", StringComparison.Ordinal)); AssertConflictLocation(run, "Generated member", "Specs/first.cheatengine-sdk-api.txt", 4, 10); AssertConflictLocation(run, "Generated member", "Specs/second.cheatengine-sdk-api.txt", 4, 10); AssertConflictLocation(run, "Generated cache field", "Specs/first.cheatengine-sdk-api.txt", 3, 10); @@ -140,7 +140,7 @@ private static void AssertConflictLocation(GeneratorRun run, string messageFragm foreach (Diagnostic diagnostic in run.GeneratorDiagnostics) { if (!diagnostic.GetMessage(CultureInfo.InvariantCulture) - .Contains(messageFragment, StringComparison.Ordinal)) + .Contains(messageFragment, StringComparison.Ordinal)) { continue; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs index 11923f81..3755b56f 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Generator/IncrementalityTests.cs @@ -126,15 +126,17 @@ public void Pipeline_step_values_hold_no_roslyn_objects() foreach (string stepName in EngineApiTrackingNames.All) { if (string.Equals(stepName, EngineApiTrackingNames.SpecTextFile, StringComparison.Ordinal)) - // Legitimately holds the raw AdditionalText: that is the point of this filter step. + // Legitimately holds the raw AdditionalText: that is the point of this filter step. { continue; } foreach (IncrementalGeneratorRunStep step in run.Result.TrackedSteps[stepName]) - foreach ((object value, IncrementalStepRunReason _) in step.Outputs) { - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs index 99962168..49ab4a53 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/GeneratedAssembly.cs @@ -43,7 +43,7 @@ public static GeneratedAssembly Load(GeneratorRun run) image.Position = 0; string assemblyName = "CheatEngine.SDK.EngineApi.Tests." + - Interlocked.Increment(ref s_counter).ToString(CultureInfo.InvariantCulture); + Interlocked.Increment(ref s_counter).ToString(CultureInfo.InvariantCulture); AssemblyLoadContext context = new(assemblyName); return new GeneratedAssembly(context.LoadFromStream(image)); } @@ -56,7 +56,7 @@ public TDelegate Delegate(string typeName, string methodName) Type[] parameterTypes = [.. parameters.Select(static parameter => parameter.ParameterType)]; Type type = Assembly.GetType(typeName, true)!; MethodInfo method = type.GetMethod(methodName, StaticMembers, parameterTypes) ?? - throw new MissingMethodException(typeName, methodName); + throw new MissingMethodException(typeName, methodName); return method.CreateDelegate(); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs index b9de061c..0a34b77d 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -61,8 +61,8 @@ public static ImmutableArray FromTargetingPack() { string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out Version? version) - && (bestVersion is null || version > bestVersion)) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) { best = candidate; bestVersion = version; diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs index a4030574..3a7c5c80 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Infrastructure/ModelGraph.cs @@ -69,7 +69,7 @@ private static void Visit(object? value, string path, HashSet visited, i } foreach (FieldInfo field in - type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs index 0a14a9fd..000ad62e 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/Parsing/SpecFileParserTests.cs @@ -98,7 +98,7 @@ public void Indentation_and_CRLF_line_endings_are_tolerated() string text = SpecSources.SingleTry.Replace("\r\n", "\n", StringComparison.Ordinal) .Replace("\n", "\r\n", StringComparison.Ordinal); text = " namespace: Demo.One\r\n type: One\r\n\r\n" + - text[text.IndexOf("global:", StringComparison.Ordinal)..]; + text[text.IndexOf("global:", StringComparison.Ordinal)..]; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); @@ -313,7 +313,7 @@ public void A_duplicate_entry_key_drops_the_entry() public void An_invalid_lua_global_name_drops_the_entry(string badName) { string text = "namespace: Demo\ntype: T\n\nglobal: " + badName + - "\nmethod: M\nform: try\narg: address:address\nresult: value:int32\ndoc: d.\n"; + "\nmethod: M\nform: try\narg: address:address\nresult: value:int32\ndoc: d.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); @@ -481,7 +481,7 @@ public void A_throwing_entry_without_a_return_is_a_void_wrapper() public void A_malformed_or_unknown_kind_argument_drops_the_entry(string argLine) { string text = "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: M\nform: try\n" + argLine + - "\nresult: value:int32\ndoc: d.\n"; + "\nresult: value:int32\ndoc: d.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); @@ -496,7 +496,7 @@ public void A_malformed_or_unknown_kind_argument_drops_the_entry(string argLine) public void A_fixed_argument_accepts_only_boolean_literals(string fixedLine) { string text = "namespace: Demo\ntype: T\n\nglobal: readInteger\nmethod: M\nform: try\narg: address:address\n" + - fixedLine + "\nresult: value:int32\ndoc: d.\n"; + fixedLine + "\nresult: value:int32\ndoc: d.\n"; SpecFileModel spec = SpecFileParser.Parse("x.cheatengine-sdk-api.txt", text); diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs index 5399c285..eb0bdbdc 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/ContractIdentityTests.cs @@ -187,8 +187,8 @@ private static void AssertNoSourceErrors(GeneratorRun run) Assert.DoesNotContain( run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken), static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error - && (diagnostic.Location.SourceTree is null - || !diagnostic.Location.SourceTree.FilePath.EndsWith(".g.cs", - StringComparison.Ordinal))); + && (diagnostic.Location.SourceTree is null + || !diagnostic.Location.SourceTree.FilePath.EndsWith(".g.cs", + StringComparison.Ordinal))); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs index 249eef09..6700113a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/IncrementalityTests.cs @@ -260,10 +260,14 @@ public void Pipeline_step_values_hold_no_roslyn_objects() int visited = 0; foreach (string stepName in EntryPointTrackingNames.All) - foreach (IncrementalGeneratorRunStep step in run.Result.TrackedSteps[stepName]) - foreach ((object value, IncrementalStepRunReason _) in step.Outputs) { - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + foreach (IncrementalGeneratorRunStep step in run.Result.TrackedSteps[stepName]) + { + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } + } } Assert.True(visited > 0, "No model object was visited: the assertion would be vacuous."); diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs index d6c8333c..025f7679 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Generator/RealAssemblyCompilationTests.cs @@ -87,7 +87,7 @@ [new EntryPointGenerator().AsSourceGenerator()], .. outputCompilation .GetDiagnostics(TestContext.Current.CancellationToken) .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) + !IsMissingDocumentationInTestInput(diagnostic)) ]; Assert.True( @@ -101,7 +101,7 @@ .. outputCompilation private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) { return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs index ff87ce3a..fcf3ffe4 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/GeneratorRun.cs @@ -76,7 +76,7 @@ public void AssertCompilesClean() .. OutputCompilation .GetDiagnostics(TestContext.Current.CancellationToken) .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) + !IsMissingDocumentationInTestInput(diagnostic)) ]; Assert.True(problems.Length == 0, @@ -88,7 +88,7 @@ .. OutputCompilation private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) { return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs index 138fd492..3a6d4aa7 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LoadedBootstrap.cs @@ -25,7 +25,7 @@ private LoadedBootstrap(AssemblyLoadContext context, Assembly hosting, Assembly // Same lookup as the host: type 'CESDK.CESDK' in the plugin assembly, static method 'CEPluginInitialize'. Type entryPoint = plugin.GetType("CESDK.CESDK", true)!; MethodInfo method = entryPoint.GetMethod("CEPluginInitialize", BindingFlags.Public | BindingFlags.Static) - ?? throw new MissingMethodException("CESDK.CESDK", "CEPluginInitialize"); + ?? throw new MissingMethodException("CESDK.CESDK", "CEPluginInitialize"); _initialize = method.CreateDelegate>(); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs index 5cb4b2ef..678a7dff 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -63,8 +63,8 @@ public static ImmutableArray FromTargetingPack() { string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out Version? version) - && (bestVersion is null || version > bestVersion)) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) { best = candidate; bestVersion = version; diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs index ee75bfd1..357f5c77 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Infrastructure/ModelGraph.cs @@ -69,7 +69,7 @@ private static void Visit(object? value, string path, HashSet visited, i } foreach (FieldInfo field in - type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs index eaf158ee..cbb2c58a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/Model/BootstrapModelTests.cs @@ -68,17 +68,32 @@ public void PluginModel_is_valid_only_without_issues() { Assert.True(ValidA.IsValid); Assert.False(Invalid.IsValid); - Assert.False((ValidA with { Issues = PluginShapeIssues.InvalidName }).IsValid); + Assert.False((ValidA with + { + Issues = PluginShapeIssues.InvalidName + }).IsValid); } [Fact] public void Models_compare_by_value() { Assert.Equal(new PluginModel("global::A", "Plugin A", "", PluginShapeIssues.None), ValidA); - Assert.NotEqual(ValidA with { DisplayName = "other" }, ValidA); - Assert.NotEqual(ValidA with { FullyQualifiedTypeName = "global::Z" }, ValidA); - Assert.NotEqual(ValidA with { DeclaredDiagnosticIds = "EXP001" }, ValidA); - Assert.NotEqual(ValidA with { Issues = PluginShapeIssues.Static }, ValidA); + Assert.NotEqual(ValidA with + { + DisplayName = "other" + }, ValidA); + Assert.NotEqual(ValidA with + { + FullyQualifiedTypeName = "global::Z" + }, ValidA); + Assert.NotEqual(ValidA with + { + DeclaredDiagnosticIds = "EXP001" + }, ValidA); + Assert.NotEqual(ValidA with + { + Issues = PluginShapeIssues.Static + }, ValidA); Assert.Equal(new EntryPointOptions(true), On); Assert.NotEqual(Off, On); Assert.Equal( diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs index 19d9becc..26790695 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/ContainingTypeTests.cs @@ -45,7 +45,7 @@ internal static partial class Bindings public void Generator_global_namespace_has_no_namespace_block() { GeneratorRun run = roslyn.Run(Usings + - "public static partial class Top { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + "public static partial class Top { [LuaFunction(\"f\")] public static int F(int a) => a; }"); run.AssertCompilesClean(); string text = run.GeneratedText("Top.LuaFunctions.g.cs"); @@ -66,7 +66,7 @@ public void Generator_global_namespace_has_no_namespace_block() public void Generator_type_kinds_are_reopened_with_their_keyword(string declaration, string expectedPart) { GeneratorRun run = roslyn.Run(Usings + "namespace Demo; " + declaration + - " Holder { [LuaFunction(\"f\")] public static int F(int a) => a; [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); }"); + " Holder { [LuaFunction(\"f\")] public static int F(int a) => a; [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, out int v); }"); run.AssertCompilesClean(); Assert.Contains("\n " + expectedPart + " Holder\n {\n", @@ -109,7 +109,7 @@ public void Generator_types_differing_only_by_case_get_distinct_hint_names() // cannot tell "DemoType" and "demoType" apart (neither has a replaced character), so without disambiguation // AddSource throws ArgumentException on the second one and the whole generation pass crashes. const string Source = Usings + - "namespace Demo; public static partial class DemoType { [LuaFunction(\"f1\")] public static int F(int a) => a; } public static partial class demoType { [LuaFunction(\"f2\")] public static int F(int a) => a; }"; + "namespace Demo; public static partial class DemoType { [LuaFunction(\"f1\")] public static int F(int a) => a; } public static partial class demoType { [LuaFunction(\"f2\")] public static int F(int a) => a; }"; GeneratorRun run = roslyn.Run(Source); @@ -124,7 +124,7 @@ public void Generator_types_differing_only_by_case_get_distinct_hint_names() public void Generator_non_ascii_type_name_gets_a_hashed_hint_name_and_an_unescaped_declaration() { GeneratorRun run = roslyn.Run(Usings + - "namespace Demo; public static partial class Caf\u00E9 { [LuaFunction(\"f\")] public static int F(int a) => a; }"); + "namespace Demo; public static partial class Caf\u00E9 { [LuaFunction(\"f\")] public static int F(int a) => a; }"); run.AssertCompilesClean(); string hintName = Assert.Single(run.HintNames); @@ -186,8 +186,8 @@ public void Generator_type_split_across_files_gets_one_file() public void Generator_repeats_the_defining_declarations_modifiers(string declared, string emitted) { GeneratorRun run = roslyn.Run(Usings + "namespace Demo; public partial class Holder { [LuaGlobal(\"g\")] " + - declared + - " bool TryG(nuint a, out int v); }"); + declared + + " bool TryG(nuint a, out int v); }"); run.AssertCompilesClean(); Assert.Contains("\n " + emitted + " bool TryG(nuint a, out int v)\n", run.SingleGeneratedText, @@ -214,7 +214,7 @@ namespace Demo; public void Generator_old_style_partial_void_without_accessibility_is_implemented_without_one() { GeneratorRun run = roslyn.Run(Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"beep\")] static partial void Beep(); }"); + "namespace Demo; public static partial class Holder { [LuaGlobal(\"beep\")] static partial void Beep(); }"); run.AssertCompilesClean(); Assert.Contains("\n static partial void Beep()\n", run.SingleGeneratedText, StringComparison.Ordinal); @@ -224,7 +224,7 @@ public void Generator_old_style_partial_void_without_accessibility_is_implemente public void Generator_private_target_is_reachable_from_the_thunk() { GeneratorRun run = roslyn.Run(Usings + - "namespace Demo; internal static partial class Holder { [LuaFunction(\"f\")] private static int F(int a) => a; }"); + "namespace Demo; internal static partial class Holder { [LuaFunction(\"f\")] private static int F(int a) => a; }"); run.AssertCompilesClean(); Assert.Contains("global::Demo.Holder.F(__arg0)", run.SingleGeneratedText, StringComparison.Ordinal); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs index b6415ec7..53a0bcc1 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/DefaultVerifierTests.cs @@ -50,7 +50,8 @@ private static CSharpSourceGeneratorTest { // A framework moniker WITHOUT a reference-assembly package: nothing is resolved through NuGet; the // framework and the SDK come from the local installation and this process (no network). - ReferenceAssemblies = new ReferenceAssemblies("net10.0"), CompilerDiagnostics = CompilerDiagnostics.Warnings + ReferenceAssemblies = new ReferenceAssemblies("net10.0"), + CompilerDiagnostics = CompilerDiagnostics.Warnings }; // The test declarations are undocumented public members: CS1591 is theirs, not the generated file's. diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs index 3d61398c..4600049d 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/IncrementalityTests.cs @@ -37,8 +37,8 @@ public void Pipeline_first_run_tracks_every_named_step() GeneratorRun run = roslyn.Run(BindingSources.Functions, BindingSources.Globals, ObjectBindings); foreach (string stepName in run.Result.TrackedSteps.Keys - .Where(TrackingNames.IsCheatEngineSdkStep) - .Order(StringComparer.Ordinal)) + .Where(TrackingNames.IsCheatEngineSdkStep) + .Order(StringComparer.Ordinal)) { Assert.All(StepAssert.Reasons(run.Result, stepName), static reason => Assert.Equal(IncrementalStepRunReason.New, reason)); @@ -286,17 +286,19 @@ public void Pipeline_step_values_hold_no_roslyn_objects() int visited = 0; foreach (string stepName in run.Result.TrackedSteps.Keys - .Where(TrackingNames.IsCheatEngineSdkStep) - .Order(StringComparer.Ordinal)) + .Where(TrackingNames.IsCheatEngineSdkStep) + .Order(StringComparer.Ordinal)) { Assert.True( run.Result.TrackedSteps.TryGetValue(stepName, out ImmutableArray steps), $"Tracked step '{stepName}' was not present."); foreach (IncrementalGeneratorRunStep step in steps) - foreach ((object value, IncrementalStepRunReason _) in step.Outputs) { - visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + foreach ((object value, IncrementalStepRunReason _) in step.Outputs) + { + visited += ModelGraph.AssertFreeOfRoslynObjects(value, stepName); + } } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs index 9097b1ac..61ba2f3c 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaFunctionOutputTests.cs @@ -49,10 +49,10 @@ public void Generator_function_suite_compiles_clean_and_declares_one_thunk_per_f run.AssertCompilesClean(); string text = run.SingleGeneratedText; foreach (string name in new[] - { - "add", "greet", "ping", "isint", "boom", "echo", "half", "negate", "step", "small", "scale", - "maybe" - }) + { + "add", "greet", "ping", "isint", "boom", "echo", "half", "negate", "step", "small", "scale", + "maybe" + }) { Assert.Contains("private static int __LuaThunk_" + name + "(nint __handle)", text, StringComparison.Ordinal); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs index c65929b2..fb3a4c76 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaGlobalOutputTests.cs @@ -238,9 +238,9 @@ public void Generator_unannotated_out_string_result_gets_exactly_cs8601_the_docu .. run.OutputCompilation .GetDiagnostics(TestContext.Current.CancellationToken) .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning - && !(string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } && - !path.EndsWith(".g.cs", StringComparison.Ordinal))) + && !(string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) + && diagnostic.Location.SourceTree is { FilePath: string path } && + !path.EndsWith(".g.cs", StringComparison.Ordinal))) ]; Diagnostic problem = Assert.Single(problems); Assert.Equal("CS8601", problem.Id); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs index 7a50be69..d8e451f5 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs @@ -615,7 +615,7 @@ private static MethodDeclarationSyntax FindGeneratedMethod(CompilationUnitSyntax foreach (SyntaxNode node in root.DescendantNodes()) { if (node is MethodDeclarationSyntax candidate - && string.Equals(candidate.Identifier.ValueText, methodName, StringComparison.Ordinal)) + && string.Equals(candidate.Identifier.ValueText, methodName, StringComparison.Ordinal)) { Assert.Null(result); result = candidate; diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs index 98c41bb8..12059e93 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/NoOutputTests.cs @@ -393,7 +393,7 @@ public void Generator_invalid_global_shape_emits_nothing(string shape, string so public void Generator_no_attribute_emits_nothing() { GeneratorRun run = roslyn.Run(Usings + - "public static partial class T { public static int F(int a) => a; public static partial int G(int a); public static partial int G(int a) => a; }"); + "public static partial class T { public static int F(int a) => a; public static partial int G(int a); public static partial int G(int a) => a; }"); run.AssertNoOutput(); } @@ -406,7 +406,7 @@ public void Generator_same_name_source_LuaState_without_the_sdk_runtime_emits_no foreach (MetadataReference reference in roslyn.Environment.SdkReferences) { if (string.Equals(reference.Display, typeof(LuaState).Assembly.Location, - StringComparison.OrdinalIgnoreCase)) + StringComparison.OrdinalIgnoreCase)) { continue; } @@ -560,8 +560,8 @@ private static int Count(string text, string needle) { int count = 0; for (int index = text.IndexOf(needle, StringComparison.Ordinal); - index >= 0; - index = text.IndexOf(needle, index + needle.Length, StringComparison.Ordinal)) + index >= 0; + index = text.IndexOf(needle, index + needle.Length, StringComparison.Ordinal)) { count++; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs index 6fa31a12..1e0e40d0 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/PartialMethodSignatureTests.cs @@ -15,7 +15,7 @@ public sealed class PartialMethodSignatureTests(RoslynFixture roslyn) : IClassFi public void Generator_repeats_an_explicit_scoped_readonlyspan_argument() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(scoped System.ReadOnlySpan data, out int v); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(scoped System.ReadOnlySpan data, out int v); }"; GeneratorRun run = roslyn.Run(Source); @@ -28,7 +28,7 @@ public void Generator_repeats_an_explicit_scoped_readonlyspan_argument() public void Generator_omits_scoped_when_the_defining_declaration_did() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(System.ReadOnlySpan data, out int v); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(System.ReadOnlySpan data, out int v); }"; GeneratorRun run = roslyn.Run(Source); @@ -43,7 +43,7 @@ public void Generator_omits_scoped_when_the_defining_declaration_did() public void Generator_repeats_an_explicit_scoped_copyout_destination() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped System.Span destination, out int written); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped System.Span destination, out int written); }"; GeneratorRun run = roslyn.Run(Source); @@ -56,7 +56,7 @@ public void Generator_repeats_an_explicit_scoped_copyout_destination() public void Generator_omits_scoped_on_the_copyout_destination_when_the_defining_declaration_did() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, System.Span destination, out int written); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, System.Span destination, out int written); }"; GeneratorRun run = roslyn.Run(Source); @@ -72,7 +72,7 @@ public void Generator_scoped_out_result_of_a_non_ref_struct_type_needs_no_specia // 'scoped' on an 'out' parameter of a non-ref-struct type does not affect partial-signature matching (the // compiler accepts a mismatch there), so the emitter never needs to repeat it; this pins that down. const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped out int v); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint a, scoped out int v); }"; GeneratorRun run = roslyn.Run(Source); @@ -85,7 +85,7 @@ public void Generator_scoped_out_result_of_a_non_ref_struct_type_needs_no_specia public void Generator_preserves_the_extension_receiver_in_the_implementing_declaration() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this nuint address, out int value); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this nuint address, out int value); }"; GeneratorRun run = roslyn.Run(Source); @@ -98,7 +98,7 @@ public void Generator_preserves_the_extension_receiver_in_the_implementing_decla public void Generator_preserves_the_extension_receiver_on_a_copyout_destination() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this System.Span destination, out int written); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(this System.Span destination, out int written); }"; GeneratorRun run = roslyn.Run(Source); @@ -111,7 +111,7 @@ public void Generator_preserves_the_extension_receiver_on_a_copyout_destination( public void Generator_qualifies_the_cache_when_a_parameter_uses_its_name() { const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial void G(int s_luaGlobal_g); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial void G(int s_luaGlobal_g); }"; GeneratorRun run = roslyn.Run(Source); @@ -126,7 +126,7 @@ public void Generator_parameter_named_like_a_generated_local_is_skipped_without_ // A generated partial body shares its parameter scope with the defining declaration. Do not emit CS0136 and // leave the analyzer to report the precise CESDK2007 collision at the author declaration. const string Source = Usings + - "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint __L, out int v); }"; + "namespace Demo; public static partial class Holder { [LuaGlobal(\"g\")] public static partial bool TryG(nuint __L, out int v); }"; GeneratorRun run = roslyn.Run(Source); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs index 72cd2bc4..959c4c4f 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/GeneratorRun.cs @@ -94,7 +94,7 @@ public void AssertCompilesClean() .. OutputCompilation .GetDiagnostics(TestContext.Current.CancellationToken) .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && - !IsMissingDocumentationInTestInput(diagnostic)) + !IsMissingDocumentationInTestInput(diagnostic)) ]; Assert.True(problems.Length == 0, @@ -106,7 +106,7 @@ .. OutputCompilation private static bool IsMissingDocumentationInTestInput(Diagnostic diagnostic) { return string.Equals(diagnostic.Id, "CS1591", StringComparison.Ordinal) - && diagnostic.Location.SourceTree is { FilePath: string path } - && !path.EndsWith(".g.cs", StringComparison.Ordinal); + && diagnostic.Location.SourceTree is { FilePath: string path } + && !path.EndsWith(".g.cs", StringComparison.Ordinal); } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs index 8b157554..b179ad3a 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/LocalFrameworkReferences.cs @@ -63,8 +63,8 @@ public static ImmutableArray FromTargetingPack() { string candidate = Path.Combine(pack, "ref", TargetFrameworkFolder); if (Directory.Exists(candidate) - && TryParsePackVersion(Path.GetFileName(pack), out Version? version) - && (bestVersion is null || version > bestVersion)) + && TryParsePackVersion(Path.GetFileName(pack), out Version? version) + && (bestVersion is null || version > bestVersion)) { best = candidate; bestVersion = version; diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs index 27df8352..4517cfd2 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Infrastructure/ModelGraph.cs @@ -70,7 +70,7 @@ private static void Visit(object? value, string path, HashSet visited, i } foreach (FieldInfo field in - type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { Visit(field.GetValue(value), $"{path}.{field.Name}", visited, depth + 1); } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs index 2b56bfe2..21d707fa 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaFunctionTablesTests.cs @@ -58,10 +58,14 @@ public void Group_skips_invalid_members_and_types_left_without_a_valid_one() { LuaFunctionModel invalid = Function(Alpha, "bad") with { - Issues = LuaFunctionShapeIssues.NotStatic, Thunk = null + Issues = LuaFunctionShapeIssues.NotStatic, + Thunk = null }; LuaFunctionModel invalidType = - Function(Zeta, "ok") with { ContainingTypeIssues = ContainingTypeIssues.NotPartial }; + Function(Zeta, "ok") with + { + ContainingTypeIssues = ContainingTypeIssues.NotPartial + }; EquatableArray tables = LuaFunctionTables.Group([invalid, invalidType, Function(Alpha, "good")]); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs index 87a5b8bc..b5ca744d 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Model/LuaGlobalTablesTests.cs @@ -38,7 +38,8 @@ public void Group_skips_invalid_members_and_empty_inputs() { LuaGlobalModel invalid = Global("g", "G()") with { - Issues = LuaGlobalShapeIssues.NotPartialDefinition, Call = null + Issues = LuaGlobalShapeIssues.NotPartialDefinition, + Call = null }; Assert.True(LuaGlobalTables.Group([invalid]).IsEmpty); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs index 27ecdc26..49049442 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/HintNamesTests.cs @@ -91,6 +91,6 @@ public void AllocateUnique_rejects_a_case_sensitive_reservation_set() private static string WithOrdinal(string hintName, string suffix, int ordinal) { return hintName[..^suffix.Length] + "_" + ordinal.ToString(CultureInfo.InvariantCulture) + - suffix; + suffix; } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs index 732aa358..38aac32d 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaApiNamesTests.cs @@ -42,7 +42,7 @@ public void Every_CheatEngine_SDK_name_written_into_generated_code_denotes_a_rea { int dot = text.LastIndexOf('.'); Type owner = FindType(text[..dot]) - ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); + ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); Assert.NotEmpty(owner.GetMember( text[(dot + 1)..^2], MemberTypes.Method, @@ -51,7 +51,7 @@ public void Every_CheatEngine_SDK_name_written_into_generated_code_denotes_a_rea else { _ = FindType(text) - ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); + ?? throw new InvalidOperationException($"{name}: no such type in the SDK assemblies."); } } } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs index 1b193a99..026f9e18 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/SharedCode/LuaGlobalCallEmitterTests.cs @@ -187,7 +187,10 @@ public void ResultCount_follows_the_form() LuaGlobalCallModel throwingVoid = new("g", "s", "static", "G", string.Empty, EquatableArray.Empty, LuaCallForm.Throwing, EquatableArray.Empty, null, false); - LuaGlobalCallModel throwingValue = throwingVoid with { ReturnKind = LuaValueKind.Double }; + LuaGlobalCallModel throwingValue = throwingVoid with + { + ReturnKind = LuaValueKind.Double + }; Assert.Equal(0, throwingVoid.ResultCount); Assert.Equal(1, throwingValue.ResultCount); diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogDiagnosticsTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogDiagnosticsTests.cs index 4447ca01..90e95006 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogDiagnosticsTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogDiagnosticsTests.cs @@ -75,16 +75,16 @@ public void A_valid_and_malformed_catalogue_both_receive_the_ambiguity_diagnosti Assert.Equal(3, run.GeneratorDiagnostics.Length); Assert.Contains(run.GeneratorDiagnostics, static diagnostic => string.Equals(diagnostic.Id, "CESDK4001", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, - StringComparison.Ordinal)); + && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, + StringComparison.Ordinal)); Assert.Contains(run.GeneratorDiagnostics, static diagnostic => string.Equals(diagnostic.Id, "CESDK4002", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, ValidPath, - StringComparison.Ordinal)); + && string.Equals(diagnostic.Location.GetLineSpan().Path, ValidPath, + StringComparison.Ordinal)); Assert.Contains(run.GeneratorDiagnostics, static diagnostic => string.Equals(diagnostic.Id, "CESDK4002", StringComparison.Ordinal) - && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, - StringComparison.Ordinal)); + && string.Equals(diagnostic.Location.GetLineSpan().Path, InvalidPath, + StringComparison.Ordinal)); } [Fact] diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs index a8311263..a50c0ecc 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/CatalogEmissionTests.cs @@ -20,7 +20,7 @@ public void Catalog_operations_emit_a_numeric_sorted_enum_and_required_bitmap() Assert.Contains("PushBytes = 0", generated, StringComparison.Ordinal); Assert.Contains("PushHostObject = 10", generated, StringComparison.Ordinal); Assert.True(generated.IndexOf("PushBytes = 0", StringComparison.Ordinal) - < generated.IndexOf("PushHostObject = 10", StringComparison.Ordinal)); + < generated.IndexOf("PushHostObject = 10", StringComparison.Ordinal)); Assert.Contains("internal const int Count = 2;", generated, StringComparison.Ordinal); Assert.Contains("internal const ulong RequiredBitmap = 0x0000000000000401UL;", generated, StringComparison.Ordinal); diff --git a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs index eb90ba44..5f3393ed 100644 --- a/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs +++ b/tests/CheatEngine.SDK.Tests.Shared/NativeLua/NativeLuaProbe.cs @@ -65,7 +65,7 @@ private static bool TryGetFullPath(string candidate, out string fullPath) return true; } catch (Exception exception) when (exception is ArgumentException or NotSupportedException - or PathTooLongException or SecurityException) + or PathTooLongException or SecurityException) { fullPath = string.Empty; return false; diff --git a/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs b/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs index d6dac8d3..39c8ddd6 100644 --- a/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs +++ b/tests/CheatEngine.SDK.Tests/Architecture/ProjectDependencyDirectionTests.cs @@ -80,7 +80,7 @@ public void Shipping_libraries_keep_the_declared_lower_layer_runtime_graph() discoveredLibraryProjects.Add(projectRelativePath); if (!ExpectedLibraryRuntimeDependencies.TryGetValue(projectRelativePath, - out string[]? expectedDependencies)) + out string[]? expectedDependencies)) { violations.Add($"{projectRelativePath}: is not declared in the shipping library graph."); continue; @@ -236,18 +236,20 @@ public void SDK_build_metadata_has_no_Client_or_Mcp_dependency() } foreach (string projectPath in EnumerateRepositoryFiles("*.csproj")) - foreach (ProjectReferenceInfo reference in ReadProjectReferences(projectPath)) { - if (reference.Include.Contains("$(", StringComparison.Ordinal)) + foreach (ProjectReferenceInfo reference in ReadProjectReferences(projectPath)) { - violations.Add( - $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' is dynamic and cannot be checked for a higher-layer dependency."); - } + if (reference.Include.Contains("$(", StringComparison.Ordinal)) + { + violations.Add( + $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' is dynamic and cannot be checked for a higher-layer dependency."); + } - if (reference.TargetRelativePath.StartsWith("../", StringComparison.Ordinal)) - { - violations.Add( - $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' escapes the SDK repository."); + if (reference.TargetRelativePath.StartsWith("../", StringComparison.Ordinal)) + { + violations.Add( + $"{GetRepositoryRelativePath(projectPath)}: ProjectReference '{reference.Include}' escapes the SDK repository."); + } } } @@ -264,7 +266,7 @@ private static void AddForbiddenDependencyViolations(List violations, st } if (value.Contains("CheatEngine.Client", StringComparison.OrdinalIgnoreCase) || - value.Contains("CheatEngine.Mcp", StringComparison.OrdinalIgnoreCase)) + value.Contains("CheatEngine.Mcp", StringComparison.OrdinalIgnoreCase)) { violations.Add( $"{GetRepositoryRelativePath(metadataPath)}: {node.LocalName} {attributeName}='{value}' references a higher layer."); @@ -327,7 +329,7 @@ private static IEnumerable EnumerateBuildMetadataFiles() private static IEnumerable EnumerateRepositoryFiles(string searchPattern) { foreach (string path in - Directory.EnumerateFiles(RepositoryLayout.Root, searchPattern, SearchOption.AllDirectories)) + Directory.EnumerateFiles(RepositoryLayout.Root, searchPattern, SearchOption.AllDirectories)) { string relativePath = GetRepositoryRelativePath(path); if (!IsGeneratedPath(relativePath)) @@ -351,20 +353,20 @@ private static string GetRepositoryRelativePath(string fullPath) private static bool IsGeneratedPath(string relativePath) { return relativePath.StartsWith("artifacts/", StringComparison.Ordinal) || - relativePath.Contains("/bin/", StringComparison.Ordinal) || - relativePath.Contains("/obj/", StringComparison.Ordinal); + relativePath.Contains("/bin/", StringComparison.Ordinal) || + relativePath.Contains("/obj/", StringComparison.Ordinal); } private static bool IsRoslynComponent(string projectRelativePath) { return projectRelativePath.StartsWith("analyzers/", StringComparison.Ordinal) || - projectRelativePath.StartsWith("source-generators/", StringComparison.Ordinal); + projectRelativePath.StartsWith("source-generators/", StringComparison.Ordinal); } private static bool IsShippingProject(string projectRelativePath) { return projectRelativePath.StartsWith("libs/", StringComparison.Ordinal) || - string.Equals(projectRelativePath, UmbrellaProject, StringComparison.Ordinal); + string.Equals(projectRelativePath, UmbrellaProject, StringComparison.Ordinal); } private static XmlDocument LoadProjectDocument(string projectPath) diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs index ac57aa94..2997b0f7 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/EntryPointProbe.cs @@ -27,7 +27,7 @@ public static (bool TypeExists, bool MethodExists) Probe(string assemblyPath) { TypeDefinition type = reader.GetTypeDefinition(typeHandle); if (!string.Equals(reader.GetString(type.Namespace), TypeNamespace, StringComparison.Ordinal) - || !string.Equals(reader.GetString(type.Name), TypeName, StringComparison.Ordinal)) + || !string.Equals(reader.GetString(type.Name), TypeName, StringComparison.Ordinal)) { continue; } @@ -36,7 +36,7 @@ public static (bool TypeExists, bool MethodExists) Probe(string assemblyPath) { MethodDefinition method = reader.GetMethodDefinition(methodHandle); if (string.Equals(reader.GetString(method.Name), MethodName, StringComparison.Ordinal) && - method.GetParameters().Count == 2) + method.GetParameters().Count == 2) { return (TypeExists: true, MethodExists: true); } diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs index 012779fc..e0418a7e 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs @@ -458,10 +458,10 @@ await Task.Delay(TimeSpan.FromSeconds(20 * attempt), TestContext.Current.Cancell private static bool LooksLikeFileLockContention(string output) { return output.Contains("being used by another process", StringComparison.OrdinalIgnoreCase) - || output.Contains("cannot access the file", StringComparison.OrdinalIgnoreCase) - || output.Contains("MSB3021", StringComparison.Ordinal) - || output.Contains("MSB3027", StringComparison.Ordinal) - || output.Contains("MSB3061", StringComparison.Ordinal); + || output.Contains("cannot access the file", StringComparison.OrdinalIgnoreCase) + || output.Contains("MSB3021", StringComparison.Ordinal) + || output.Contains("MSB3027", StringComparison.Ordinal) + || output.Contains("MSB3061", StringComparison.Ordinal); } private async Task InitializeDefaultConsumerAsync(string tempRoot, string feedDirectory, string packagesDirectory) @@ -538,7 +538,8 @@ private async Task InitializeLuaFunctionConsumersAsync(string tempRoot, string f feedDirectory, new ThrowawayConsumer.CreateOptions { - ExtraProperties = " true\n", IncludeLuaFunction = true + ExtraProperties = " true\n", + IncludeLuaFunction = true }); ProcessResult optInRestore = await optInConsumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs index 7b013832..15430f7e 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ProcessRunner.cs @@ -24,7 +24,10 @@ public static async Task RunAsync(string fileName, string argumen CreateNoWindow = true }; - using Process process = new() { StartInfo = startInfo }; + using Process process = new() + { + StartInfo = startInfo + }; StringBuilder standardOutput = new(); StringBuilder standardError = new(); process.OutputDataReceived += (_, e) => diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs index fbb0c418..2ad3cdfc 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/RepositoryLayout.cs @@ -20,8 +20,8 @@ public static string PathOf(string relativePath) private static string FindRoot() { for (DirectoryInfo? directory = new(AppContext.BaseDirectory); - directory is not null; - directory = directory.Parent) + directory is not null; + directory = directory.Parent) { if (File.Exists(Path.Combine(directory.FullName, SolutionFileName))) { From 4c833c5900cf83d04f21f2da4629e28ad8cee9d7 Mon Sep 17 00:00:00 2001 From: AriusII Date: Tue, 22 Sep 2026 23:51:42 +0200 Subject: [PATCH 002/199] Scaffold repository tests and shared package versions Add CheatEngine.SDK.Repository.Tests, a fast project that only reads committed files, with its first contract: every project on disk is in the solution unless an explicit, reasoned exclusion says otherwise. Later remediation lots add their documentation, workflow, qualification and catalogue contracts there instead of reviving script-only gates. Pre-register the package versions the parallel remediation lots need (PublicApiAnalyzers, Microsoft.Sbom.Targets, MTP HangDump and CrashDump, YamlDotNet) so they do not all edit Directory.Packages.props, make the Engine.Tests FakeHost partial so lots can extend it in separate files, and record the formatting commit in .git-blame-ignore-revs. --- .git-blame-ignore-revs | 3 + CheatEngine.SDK.slnx | 1 + Directory.Packages.props | 9 +++ .../Support/FakeHost.cs | 2 +- .../CheatEngine.SDK.Repository.Tests.csproj | 13 ++++ .../Infrastructure/RepositoryRoot.cs | 66 ++++++++++++++++++ .../README.md | 34 ++++++++++ .../Solution/SolutionInventoryTests.cs | 68 +++++++++++++++++++ 8 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 .git-blame-ignore-revs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Infrastructure/RepositoryRoot.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/README.md create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..a60f9d31 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,3 @@ +# Commits that only reformat code. Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs +# Apply repository formatting +0a03dac0cae53d399e89dfb1494ac80843a3ab9a diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 5948c8b7..48cc6b3f 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -86,6 +86,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index f07b0ced..9bc821ce 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,10 @@ + + + + @@ -27,6 +31,11 @@ + + + + + diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs index 97fa86c1..94e8ad65 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs @@ -30,7 +30,7 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// state lives in a Lua table stored as the userdata's user value (lua_setuservalue), keyed by pointer in a /// registry table so that every push of the same pointer finds the same state. /// -internal static unsafe class FakeHost // NOSONAR: the fixture implements Cheat Engine's unmanaged callback ABI. +internal static unsafe partial class FakeHost // NOSONAR: the fixture implements Cheat Engine's unmanaged callback ABI. { // Registry keys: light userdata whose values are the addresses of these bytes (stable for the process). private const int MetatableKey = 0; diff --git a/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj new file mode 100644 index 00000000..2edfc5ad --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Infrastructure/RepositoryRoot.cs b/tests/CheatEngine.SDK.Repository.Tests/Infrastructure/RepositoryRoot.cs new file mode 100644 index 00000000..d561edb2 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Infrastructure/RepositoryRoot.cs @@ -0,0 +1,66 @@ +namespace CheatEngine.SDK.Repository.Tests.Infrastructure; + +/// Locates the repository from the test output directory. +internal static class RepositoryRoot +{ + private const string SolutionFileName = "CheatEngine.SDK.slnx"; + + /// The directory that contains CheatEngine.SDK.slnx, found by walking up from the test binaries. + public static string Path + { + get; + } = FindRoot(); + + /// The solution file. + public static string SolutionPath => System.IO.Path.Combine(Path, SolutionFileName); + + /// Converts an absolute path below the repository root to a forward-slash relative path. + public static string ToRelative(string absolutePath) + { + return System.IO.Path.GetRelativePath(Path, absolutePath).Replace('\\', '/'); + } + + /// Enumerates files below the repository root, skipping build output and tool state folders. + public static IEnumerable EnumerateSourceFiles(string searchPattern) + { + foreach (string file in Directory.EnumerateFiles(Path, searchPattern, SearchOption.AllDirectories)) + { + string relative = ToRelative(file); + if (IsExcluded(relative)) + { + continue; + } + + yield return relative; + } + } + + private static bool IsExcluded(string relativePath) + { + foreach (string segment in relativePath.Split('/')) + { + if (segment is "artifacts" or "bin" or "obj" or ".git" or ".idea" or ".vs" or "TestResults") + { + return true; + } + } + + return false; + } + + private static string FindRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(System.IO.Path.Combine(directory.FullName, SolutionFileName))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException( + $"'{SolutionFileName}' was not found above '{AppContext.BaseDirectory}': the tests expect to run from the repository's artifacts directory."); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md new file mode 100644 index 00000000..d871fa95 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -0,0 +1,34 @@ +# CheatEngine.SDK.Repository.Tests + +## Objective + +Keep the repository's own contracts true: the solution inventory today, and the documentation, workflow, +qualification-matrix and catalogue contracts added by the audit remediation work. + +## Why it exists + +Several of these rules used to live in scripts that CI stopped running, so they silently rotted (dead +`documentations/` links, orphaned validators). Repository rules are enforced by C# tests instead, and they stay fast: +this project only reads committed files. It never builds, packs, restores or starts a process. + +## How it works + +| Folder | Content | +|-------------------|----------------------------------------------------------------------------------------------------| +| `Infrastructure/` | `RepositoryRoot` finds `CheatEngine.SDK.slnx` above the test binaries and enumerates source files. | +| `Solution/` | `SolutionInventoryTests` compares the projects on disk with the projects listed in the solution. | + +Later work adds one folder per contract (for example `Documentation/`, `Workflows/`, `Qualification/`). + +## Promise + +- Every `*.csproj` on disk is built by CI through the solution, unless it is listed with a reason in + `SolutionInventoryTests` (`Every_project_on_disk_is_in_the_solution_or_explicitly_excluded`). +- The solution lists no missing project and the exclusion list holds no stale entry + (`Every_project_in_the_solution_exists_and_no_exclusion_is_stale`). + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj +``` diff --git a/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs new file mode 100644 index 00000000..b4256501 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs @@ -0,0 +1,68 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Solution; + +/// The solution is the single inventory CI builds and tests; a project outside it is never compiled. +public sealed class SolutionInventoryTests +{ + /// Projects deliberately kept out of the solution, with the reason. Adding one is a review decision. + private static readonly Dictionary s_outOfSolution = new(StringComparer.Ordinal) + { + ["tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj"] = + "Native AOT executable probe, restored and published on its own by the CI aot job.", + ["tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj"] = + "Manually loaded CE 7.7 evidence harness; the qualification work brings it into the solution." + }; + + [Fact] + public void Every_project_on_disk_is_in_the_solution_or_explicitly_excluded() + { + HashSet listed = ReadSolutionProjects(); + List missing = []; + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + if (!listed.Contains(project) && !s_outOfSolution.ContainsKey(project)) + { + missing.Add(project); + } + } + + Assert.True(missing.Count == 0, + $"Add these projects to CheatEngine.SDK.slnx (dotnet sln add) or justify them in {nameof(s_outOfSolution)}: {string.Join(", ", missing)}"); + } + + [Fact] + public void Every_project_in_the_solution_exists_and_no_exclusion_is_stale() + { + HashSet listed = ReadSolutionProjects(); + foreach (string project in listed) + { + Assert.True(File.Exists(Path.Combine(RepositoryRoot.Path, project)), + $"CheatEngine.SDK.slnx lists '{project}', which does not exist."); + } + + foreach (string excluded in s_outOfSolution.Keys) + { + Assert.True(File.Exists(Path.Combine(RepositoryRoot.Path, excluded)), + $"The exclusion '{excluded}' names a project that no longer exists."); + Assert.False(listed.Contains(excluded), + $"'{excluded}' is in the solution now; remove it from {nameof(s_outOfSolution)}."); + } + } + + private static HashSet ReadSolutionProjects() + { + XDocument solution = XDocument.Load(RepositoryRoot.SolutionPath); + HashSet projects = new(StringComparer.Ordinal); + foreach (XElement project in solution.Descendants("Project")) + { + string? path = (string?) project.Attribute("Path"); + if (path is not null) + { + projects.Add(path.Replace('\\', '/')); + } + } + + return projects; + } +} From 2d7a01a84efaf26d54575bd3d77c25669933a818 Mon Sep 17 00:00:00 2001 From: AriusII Date: Tue, 22 Sep 2026 23:55:41 +0200 Subject: [PATCH 003/199] Declare CRLF line endings in .editorconfig dotnet format left LF-only files untouched while the IDE0055 build check expects the platform newline, so a file written with LF endings failed the build without being fixable by the formatter. Declare end_of_line = crlf to match .gitattributes, keeping the two LF-pinned native bridge inputs on LF. --- .editorconfig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.editorconfig b/.editorconfig index c02c664f..fe741e41 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,8 @@ indent_size = 4 tab_width = 4 max_line_length = 120 trim_trailing_whitespace = true +# Matches .gitattributes (eol=crlf): dotnet format and the IDE0055 build check agree on CRLF. +end_of_line = crlf [*.{csproj,props,targets,slnx,config,json,yml,yaml}] # YAML cannot use tabs for indentation. Keep project and configuration files @@ -146,3 +148,7 @@ dotnet_diagnostic.IDE0005.severity = error [src/**.cs] dotnet_diagnostic.IDE0005.severity = error + +# The native bridge embeds the SHA-256 of these two files; .gitattributes pins them to LF. +[native/cheatengine-sdk-lua-bridge/{cheatengine_sdk_lua_bridge.c,xmake.lua}] +end_of_line = lf From fc6016532f5f1d963d1cf267a1750bbbfa64cc49 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:01:04 +0200 Subject: [PATCH 004/199] Format the projects that live outside the solution AotProbe and LiveProbe are restored and built on their own, so the solution-wide formatting pass skipped them and the CI aot job now failed on IDE0055 and IDE0011 once those rules became build errors. Apply dotnet format whitespace and style to both projects. LiveProbe still does not compile for an unrelated, pre-existing reason; the qualification work brings it into the solution and fixes it. --- tests/CheatEngine.SDK.AotProbe/Program.cs | 71 +++++----- .../CE77LiveProbeBootstrap.cs | 47 +++---- .../Ce77LiveProbePlugin.cs | 47 +++---- .../CheatEngine.SDK.LiveProbe/ProbeConsole.cs | 126 +++++++++--------- .../ProbeHostGlobals.cs | 6 +- .../ProbePluginFactory.cs | 10 +- 6 files changed, 157 insertions(+), 150 deletions(-) diff --git a/tests/CheatEngine.SDK.AotProbe/Program.cs b/tests/CheatEngine.SDK.AotProbe/Program.cs index a303097a..d9d63f02 100644 --- a/tests/CheatEngine.SDK.AotProbe/Program.cs +++ b/tests/CheatEngine.SDK.AotProbe/Program.cs @@ -1,8 +1,9 @@ using System.Diagnostics.CodeAnalysis; -using CheatEngine.SDK.Engine.Assembly; -using CheatEngine.SDK.Engine.Runtime; + using CheatEngine.SDK.Engine.AddressList; +using CheatEngine.SDK.Engine.Assembly; using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Lua.Callbacks; @@ -13,35 +14,39 @@ namespace CheatEngine.SDK.AotProbe; internal static class Program { - public static void Main() - { - Console.WriteLine($"CheatEngine.SDK Native AOT publication probe: {ProbeRepresentativePaths()}, {typeof(PluginHost).Assembly.GetName().Name}"); - } - - [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(LuaCallback))] - private static string ProbeRepresentativePaths() - { - if (!Address.TryParse("140001000", out Address address) || address.ToUInt64() != 0x140001000) - throw new InvalidOperationException("Address probe failed."); - - LuaStatus status = KeepGenericPath(LuaStatus.Ok); - _ = typeof(LuaCallback); - LuaRegistrationCollisionPolicy collisionPolicy = KeepGenericPath(LuaRegistrationCollisionPolicy.RejectExisting); - _ = typeof(LuaRegistrationSet); - _ = typeof(LuaRegistrationLease); - if (!InstructionProfile.X64.IsValid || InstructionProfile.X86.AddressWidth != PointerSize.Bit32) - throw new InvalidOperationException("Instruction profile probe failed."); - - _ = default(InstructionDisassembly); - _ = default(InstructionTargetProfile); - _ = typeof(AddressListMutations); - _ = typeof(MemoryRecordMutationOutcome); - _ = typeof(SymbolRegistrationLease); - _ = typeof(SymbolRegistrationReleaseOutcome); - return $"{typeof(Address).Assembly.GetName().Name}, {status}, {collisionPolicy}, {InstructionOperationStatus.Success}"; - } - - private static T KeepGenericPath(T value) where T : struct => value; - - private sealed class ProbeState; + public static void Main() + { + Console.WriteLine($"CheatEngine.SDK Native AOT publication probe: {ProbeRepresentativePaths()}, {typeof(PluginHost).Assembly.GetName().Name}"); + } + + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(LuaCallback))] + private static string ProbeRepresentativePaths() + { + if (!Address.TryParse("140001000", out Address address) || address.ToUInt64() != 0x140001000) + { + throw new InvalidOperationException("Address probe failed."); + } + + LuaStatus status = KeepGenericPath(LuaStatus.Ok); + _ = typeof(LuaCallback); + LuaRegistrationCollisionPolicy collisionPolicy = KeepGenericPath(LuaRegistrationCollisionPolicy.RejectExisting); + _ = typeof(LuaRegistrationSet); + _ = typeof(LuaRegistrationLease); + if (!InstructionProfile.X64.IsValid || InstructionProfile.X86.AddressWidth != PointerSize.Bit32) + { + throw new InvalidOperationException("Instruction profile probe failed."); + } + + _ = default(InstructionDisassembly); + _ = default(InstructionTargetProfile); + _ = typeof(AddressListMutations); + _ = typeof(MemoryRecordMutationOutcome); + _ = typeof(SymbolRegistrationLease); + _ = typeof(SymbolRegistrationReleaseOutcome); + return $"{typeof(Address).Assembly.GetName().Name}, {status}, {collisionPolicy}, {InstructionOperationStatus.Success}"; + } + + private static T KeepGenericPath(T value) where T : struct => value; + + private sealed class ProbeState; } diff --git a/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs b/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs index c761576a..0ac29503 100644 --- a/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs +++ b/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Diagnostics; + using LiveProbe; namespace CESDK; @@ -17,29 +18,29 @@ namespace CESDK; #pragma warning disable MA0049 // CE's host requires CESDK.CESDK exactly. public static unsafe class CESDK { - /// - /// CE's fixed managed component entry point. The second argument is recorded raw and deliberately has no - /// inferred semantic. - /// - /// Host-owned bootstrap storage. - /// The unmodified second integer provided by CE. - /// One only when the production bootstrap wrote its packed record. - public static int CEPluginInitialize(nint initRecord, int opaqueHostArgument) - { - try - { - LiveProbeState.CaptureBootstrap(initRecord, opaqueHostArgument); - var result = PluginHost.InitializeManaged(initRecord, opaqueHostArgument); - LiveProbeState.TryWriteTailCanaryAfterPackedRecord(initRecord, result); - return result; - } - catch (Exception exception) - { - // Do not let even the probe's diagnostics escape through hostfxr's component entry point. - HostLog.Write(HostLogLevel.Error, "CE 7.7 live-probe bootstrap failed.", exception); - return ManagedEntryPoint.Failure; - } - } + /// + /// CE's fixed managed component entry point. The second argument is recorded raw and deliberately has no + /// inferred semantic. + /// + /// Host-owned bootstrap storage. + /// The unmodified second integer provided by CE. + /// One only when the production bootstrap wrote its packed record. + public static int CEPluginInitialize(nint initRecord, int opaqueHostArgument) + { + try + { + LiveProbeState.CaptureBootstrap(initRecord, opaqueHostArgument); + int result = PluginHost.InitializeManaged(initRecord, opaqueHostArgument); + LiveProbeState.TryWriteTailCanaryAfterPackedRecord(initRecord, result); + return result; + } + catch (Exception exception) + { + // Do not let even the probe's diagnostics escape through hostfxr's component entry point. + HostLog.Write(HostLogLevel.Error, "CE 7.7 live-probe bootstrap failed.", exception); + return ManagedEntryPoint.Failure; + } + } } #pragma warning restore MA0049 #pragma warning restore MA0048 diff --git a/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs b/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs index 2cf101e3..cbdc07e5 100644 --- a/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs +++ b/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; @@ -14,30 +15,30 @@ namespace LiveProbe; /// internal sealed class Ce77LiveProbePlugin : CheatEnginePlugin { - /// - protected override void OnEnable() - { - var state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); - var registration = ProbeConsole.RegisterLuaFunctions(state); - HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - string.Create(CultureInfo.InvariantCulture, - $"CE 7.7 live probe: console command registration -> {registration}.")); + /// + protected override void OnEnable() + { + var state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); + var registration = ProbeConsole.RegisterLuaFunctions(state); + HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + string.Create(CultureInfo.InvariantCulture, + $"CE 7.7 live probe: console command registration -> {registration}.")); - LiveProbeState.ValidateAfterEnable(); - HostLog.Write(HostLogLevel.Information, LiveProbeState.GetStatus()); - } + LiveProbeState.ValidateAfterEnable(); + HostLog.Write(HostLogLevel.Information, LiveProbeState.GetStatus()); + } - /// - protected override void OnDisable() - { - var state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); - var registration = ProbeConsole.UnregisterLuaFunctions(state); - HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, - string.Create(CultureInfo.InvariantCulture, - $"CE 7.7 live probe: console command unregistration -> {registration}.")); + /// + protected override void OnDisable() + { + var state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); + var registration = ProbeConsole.UnregisterLuaFunctions(state); + HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, + string.Create(CultureInfo.InvariantCulture, + $"CE 7.7 live probe: console command unregistration -> {registration}.")); - // Deliberately do not dispose the callback-shutdown probe here. LuaRuntime.Detach, which runs immediately after - // OnDisable, is the system under test: it must neutralize the callback before freeing its GCHandle. - LiveProbeState.RecordDisable(); - } + // Deliberately do not dispose the callback-shutdown probe here. LuaRuntime.Detach, which runs immediately after + // OnDisable, is the system under test: it must neutralize the callback before freeing its GCHandle. + LiveProbeState.RecordDisable(); + } } diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs b/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs index 47bdecb2..f81fb0ad 100644 --- a/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs +++ b/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs @@ -8,76 +8,76 @@ namespace LiveProbe; /// internal static partial class ProbeConsole { - /// Returns the gate decision and all observations accumulated in this process. - [LuaFunction("ce77_live_probe_status")] - public static string Status() - { - return LiveProbeState.GetStatus(); - } + /// Returns the gate decision and all observations accumulated in this process. + [LuaFunction("ce77_live_probe_status")] + public static string Status() + { + return LiveProbeState.GetStatus(); + } - /// Captures a JSON identity record for the authorized CE host, Lua, bridge, plugin, and disposable target. - [LuaFunction("ce77_live_probe_host_profile")] - public static string HostProfile() - { - return LiveProbeState.CaptureHostProfile(); - } + /// Captures a JSON identity record for the authorized CE host, Lua, bridge, plugin, and disposable target. + [LuaFunction("ce77_live_probe_host_profile")] + public static string HostProfile() + { + return LiveProbeState.CaptureHostProfile(); + } - /// Starts the non-mutating worker-to-GUI synchronize probe and returns immediately. - [LuaFunction("ce77_live_probe_begin_synchronize")] - public static string BeginSynchronize() - { - return LiveProbeState.BeginSynchronizeProbe(); - } + /// Starts the non-mutating worker-to-GUI synchronize probe and returns immediately. + [LuaFunction("ce77_live_probe_begin_synchronize")] + public static string BeginSynchronize() + { + return LiveProbeState.BeginSynchronizeProbe(); + } - /// Returns the most recent synchronize probe result without waiting or pumping the GUI thread. - [LuaFunction("ce77_live_probe_synchronize_status")] - public static string SynchronizeStatus() - { - return LiveProbeState.GetSynchronizeStatus(); - } + /// Returns the most recent synchronize probe result without waiting or pumping the GUI thread. + [LuaFunction("ce77_live_probe_synchronize_status")] + public static string SynchronizeStatus() + { + return LiveProbeState.GetSynchronizeStatus(); + } - /// Starts the worker Lua-state and shared-registry observation and returns immediately. - [LuaFunction("ce77_live_probe_begin_lua_threads")] - public static string BeginLuaThreads() - { - return LiveProbeState.BeginLuaThreadProbe(); - } + /// Starts the worker Lua-state and shared-registry observation and returns immediately. + [LuaFunction("ce77_live_probe_begin_lua_threads")] + public static string BeginLuaThreads() + { + return LiveProbeState.BeginLuaThreadProbe(); + } - /// Returns the worker Lua-state and shared-registry observation. - [LuaFunction("ce77_live_probe_lua_threads_status")] - public static string LuaThreadsStatus() - { - return LiveProbeState.GetLuaThreadStatus(); - } + /// Returns the worker Lua-state and shared-registry observation. + [LuaFunction("ce77_live_probe_lua_threads_status")] + public static string LuaThreadsStatus() + { + return LiveProbeState.GetLuaThreadStatus(); + } - /// Captures a Lua-state/reference snapshot before an operator manually calls CE's resetLuaState(). - [LuaFunction("ce77_live_probe_snapshot_before_reset")] - public static string SnapshotBeforeReset() - { - return LiveProbeState.SnapshotBeforeReset(); - } + /// Captures a Lua-state/reference snapshot before an operator manually calls CE's resetLuaState(). + [LuaFunction("ce77_live_probe_snapshot_before_reset")] + public static string SnapshotBeforeReset() + { + return LiveProbeState.SnapshotBeforeReset(); + } - /// Captures a second snapshot after an operator manually called CE's resetLuaState(). - [LuaFunction("ce77_live_probe_snapshot_after_reset")] - public static string SnapshotAfterReset() - { - return LiveProbeState.SnapshotAfterReset(); - } + /// Captures a second snapshot after an operator manually called CE's resetLuaState(). + [LuaFunction("ce77_live_probe_snapshot_after_reset")] + public static string SnapshotAfterReset() + { + return LiveProbeState.SnapshotAfterReset(); + } - /// Observes the type and identity text of CE's getMainForm() userdata without retaining it. - [LuaFunction("ce77_live_probe_userdata")] - public static string ObserveUserdata() - { - return LiveProbeState.ObserveHostUserdata(); - } + /// Observes the type and identity text of CE's getMainForm() userdata without retaining it. + [LuaFunction("ce77_live_probe_userdata")] + public static string ObserveUserdata() + { + return LiveProbeState.ObserveHostUserdata(); + } - /// - /// Registers a harmless managed callback, intentionally leaves it registered, then asks the operator to disable - /// the plugin and call it through pcall. This tests LuaRuntime.Detach's callback neutralization. - /// - [LuaFunction("ce77_live_probe_prepare_callback_shutdown")] - public static string PrepareCallbackShutdown() - { - return LiveProbeState.PrepareCallbackShutdownProbe(); - } + /// + /// Registers a harmless managed callback, intentionally leaves it registered, then asks the operator to disable + /// the plugin and call it through pcall. This tests LuaRuntime.Detach's callback neutralization. + /// + [LuaFunction("ce77_live_probe_prepare_callback_shutdown")] + public static string PrepareCallbackShutdown() + { + return LiveProbeState.PrepareCallbackShutdownProbe(); + } } diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbeHostGlobals.cs b/tests/CheatEngine.SDK.LiveProbe/ProbeHostGlobals.cs index 45c8a72f..a8f8a5f4 100644 --- a/tests/CheatEngine.SDK.LiveProbe/ProbeHostGlobals.cs +++ b/tests/CheatEngine.SDK.LiveProbe/ProbeHostGlobals.cs @@ -5,7 +5,7 @@ namespace LiveProbe; /// Minimal CE Lua globals required only to verify the operator's target attachment. internal static partial class ProbeHostGlobals { - /// Reads CE's current selected process identifier. - [LuaGlobal("getOpenedProcessID")] - internal static partial long GetOpenedProcessId(); + /// Reads CE's current selected process identifier. + [LuaGlobal("getOpenedProcessID")] + internal static partial long GetOpenedProcessId(); } diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs index 95be9406..8a7b14e3 100644 --- a/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs +++ b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs @@ -6,10 +6,10 @@ namespace LiveProbe; // Cheat Engine before PluginHost sees it, and must not give that value a size/version meaning. internal sealed class ProbePluginFactory : IPluginFactory { - public static ReadOnlySpan Utf8Name => "CheatEngine.SDK CE 7.7 Live Probe"u8; + public static ReadOnlySpan Utf8Name => "CheatEngine.SDK CE 7.7 Live Probe"u8; - public static CheatEnginePlugin Create() - { - return new Ce77LiveProbePlugin(); - } + public static CheatEnginePlugin Create() + { + return new Ce77LiveProbePlugin(); + } } From d8f6a870d10b2a19e7827b6e903fed2030b7d3f1 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:04:24 +0200 Subject: [PATCH 005/199] Remove orphaned Python validators and their tests Validate-CeSurfaceCatalog.py and Validate-EngineeringManifest.py read inputs under documentations/ (the surface catalogue JSON, backlog.json, ARCHIVE_RECONCILIATION.md and the work-item pages). Commit 4020a32 deleted that tree and the CI job that ran them, so the scripts and their unit tests in eng/tests/ can no longer run and nothing references them. Repository rules are enforced by C# tests instead of scripts (audit F14, .coderabbit.yaml "no script-only gates"): the documentation integrity tests replace the link checks, the Lua surface catalogue returns with its own C# tests, and the protected-operation catalogue check moves to C# with the ABI work. The engineering backlog is retired. With no Python file left in the tree, the __pycache__/ and *.py[cod] ignore patterns go too. --- .gitignore | 3 - eng/Validate-CeSurfaceCatalog.py | 626 ------------------ eng/Validate-EngineeringManifest.py | 369 ----------- eng/tests/test_validate_ce_surface_catalog.py | 202 ------ .../test_validate_engineering_manifest.py | 138 ---- 5 files changed, 1338 deletions(-) delete mode 100644 eng/Validate-CeSurfaceCatalog.py delete mode 100644 eng/Validate-EngineeringManifest.py delete mode 100644 eng/tests/test_validate_ce_surface_catalog.py delete mode 100644 eng/tests/test_validate_engineering_manifest.py diff --git a/.gitignore b/.gitignore index 10c83311..2dd1a77c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,6 @@ TestResults/ BenchmarkDotNet.Artifacts/ *.binlog -__pycache__/ -*.py[cod] - .idea/ .vs/ .xmake/ diff --git a/eng/Validate-CeSurfaceCatalog.py b/eng/Validate-CeSurfaceCatalog.py deleted file mode 100644 index 8f3a0bcc..00000000 --- a/eng/Validate-CeSurfaceCatalog.py +++ /dev/null @@ -1,626 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the source-indexed Cheat Engine extension-surface catalogue offline.""" - -import argparse -import copy -import hashlib -import json -import sys -from pathlib import Path - - -CATALOG_DIRECTORY = Path("documentations/CheatEngine.SDK/catalog") -DECLARATIONS_FILE = "ce-7.7.0.10621-x64.declarations.json" -CAPABILITIES_FILE = "ce-7.7.0.10621-x64.capabilities.json" -CONFLICTS_FILE = "ce-7.7.0.10621-x64.conflicts.json" -HOST_PROFILES_FILE = "ce-7.7.0.10621-x64.host-profiles.json" -ADVANCED_FAMILIES_FILE = "ce-7.7.0.10621-x64.advanced-families.json" -EXPECTED_CATALOG_ID = "cheat-engine-extension-surface" -EXPECTED_CLASSIC_SOURCE_SHA256 = "B6500DF1E94D7BB011B38E173B2603197B7A1F304496D751EDE82E57E36E532F" -EXPECTED_CLASSIC_SLOT_MANIFEST_SHA256 = "AFACA989C7F117FC90D7D18CA30D8C8CB5049972ED91AC80132C209BA085BA5C" -EXPECTED_CLASSIC_SLOT_GROUPS = { - "direct-prefix-v1": 18, - "hookable-pointer-indirect-suffix": 64, - "borrowed-delphi-objects": 2, - "extension-v2": 3, - "extension-v3": 8, - "extension-v4": 60, - "extension-v5": 4, -} -EXPECTED_CALLBACK_IDS = { - "classic.callback.address-list", - "classic.callback.memory-view", - "classic.callback.debug-event", - "classic.callback.process-watcher", - "classic.callback.function-pointer-change", - "classic.callback.main-menu", - "classic.callback.disassembler-context-click", - "classic.callback.disassembler-context-popup", - "classic.callback.disassembler-render-line", - "classic.callback.auto-assembler", -} -EXPECTED_ADVANCED_FAMILY_IDS = { - "advanced.auto-assembler", - "advanced.dbvm", - "advanced.debugger", - "advanced.hashing", - "advanced.hotkeys", - "advanced.il2cpp", - "advanced.mono", - "advanced.remote-execution-injection", - "advanced.speedhack", - "advanced.structures", - "advanced.timers", - "advanced.ui-forms", -} -REQUIRED_CAPABILITY_FIELDS = { - "id", - "layer", - "symbol", - "declaration_refs", - "interop", - "thread_affinity", - "ownership", - "failure_shape", - "availability", - "qualification", - "profile_ids", - "conflict_ids", -} -REQUIRED_INTEROP_FIELDS = {"calling_convention", "parameter_widths", "result", "indirection"} -REQUIRED_OWNERSHIP_FIELDS = {"registration", "callback", "arguments"} -REQUIRED_CONFLICT_FIELDS = ( - "c_declaration", - "pascal_declaration", - "evidence", - "resolution", - "required_availability", - "blocks_live_qualification", -) -REQUIRED_ADVANCED_FAMILY_FIELDS = { - "id", - "title", - "owner", - "scope", - "host_prerequisites", - "privilege_requirements", - "inputs_results_cleanup", - "failure_modes", - "evidence_gap", - "source_status", - "source_refs", - "dependencies", - "support_axes", - "qualification_gates", - "adoption_decision", - "availability", - "qualification", - "profile_ids", -} -REQUIRED_ADVANCED_OWNER_FIELDS = {"sdk", "client"} -REQUIRED_ADVANCED_SCOPE_FIELDS = {"authorization", "target_scope", "policy"} -REQUIRED_ADVANCED_INPUT_RESULT_CLEANUP_FIELDS = {"inputs", "result", "cleanup"} -REQUIRED_ADVANCED_SUPPORT_AXES = { - "implementation", - "artifact", - "host", - "live_qualification", - "policy", - "lifecycle_cleanup", -} -REQUIRED_ADVANCED_QUALIFICATION_GATES = {"fixture", "live", "negative", "cleanup"} -ALLOWED_AVAILABILITY = { - "catalogued-only", - "fixture-only", - "implemented-with-known-contract-gap", - "mapped", - "not-a-support-claim", - "opaque", - "planned", - "source-only", - "unavailable", -} -ALLOWED_QUALIFICATION = { - "build-only", - "fixture-qualified", - "not-executed", - "not-qualified", - "source-indexed-only", - "unqualified", -} -ALLOWED_UNRESOLVED_CONFLICT_AVAILABILITY = {"opaque", "unavailable"} -ALLOWED_ADVANCED_AXIS_STATES = { - "contract-not-approved", - "not-identified", - "not-observed", - "not-qualified", - "explicit-opt-in-required", -} -ALLOWED_ADVANCED_FAMILY_AVAILABILITY = {"unavailable"} - - -def load_catalog(root: Path) -> dict[str, object]: - """Load the committed documents, returning an independent mutable graph for tests.""" - directory = root / CATALOG_DIRECTORY - result: dict[str, object] = {} - for file_name in (DECLARATIONS_FILE, CAPABILITIES_FILE, CONFLICTS_FILE, HOST_PROFILES_FILE, ADVANCED_FAMILIES_FILE): - path = directory / file_name - try: - result[file_name] = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - result[file_name] = {"_missing_file": str(path)} - except json.JSONDecodeError as error: - result[file_name] = {"_invalid_json": f"{path}: {error}"} - return copy.deepcopy(result) - - -def validate_catalog(catalog: dict[str, object], repository_root: Path) -> list[str]: - """Return all deterministic validation errors; never contact a host or a network endpoint.""" - errors: list[str] = [] - declarations = _document(catalog, DECLARATIONS_FILE, errors) - capabilities_document = _document(catalog, CAPABILITIES_FILE, errors) - conflicts_document = _document(catalog, CONFLICTS_FILE, errors) - profiles_document = _document(catalog, HOST_PROFILES_FILE, errors) - advanced_families_document = _document(catalog, ADVANCED_FAMILIES_FILE, errors) - if errors: - return errors - - for name, document in ( - (DECLARATIONS_FILE, declarations), - (CAPABILITIES_FILE, capabilities_document), - (CONFLICTS_FILE, conflicts_document), - (HOST_PROFILES_FILE, profiles_document), - (ADVANCED_FAMILIES_FILE, advanced_families_document), - ): - if document.get("schema_version") != 1: - errors.append(f"{name}: schema_version must be 1.") - if document.get("catalog_id") != EXPECTED_CATALOG_ID: - errors.append(f"{name}: catalog_id must be {EXPECTED_CATALOG_ID!r}.") - - _validate_declarations(declarations, errors) - profiles = _validate_profiles(profiles_document, errors) - capabilities = _validate_capabilities(capabilities_document, profiles, repository_root, errors) - conflicts, conflict_states = _validate_conflicts(conflicts_document, capabilities, declarations, errors) - _validate_conflicted_capabilities(capabilities, conflicts, conflict_states, errors) - _validate_examples(capabilities_document, capabilities, errors) - _validate_advanced_families(advanced_families_document, repository_root, errors) - return errors - - -def _document(catalog: dict[str, object], name: str, errors: list[str]) -> dict[str, object]: - document = catalog.get(name) - if not isinstance(document, dict): - errors.append(f"{name}: document is missing or is not an object.") - return {} - missing = document.get("_missing_file") - invalid = document.get("_invalid_json") - if isinstance(missing, str): - errors.append(f"{name}: required file is missing ({missing}).") - return {} - if isinstance(invalid, str): - errors.append(f"{name}: invalid JSON ({invalid}).") - return {} - if not document: - errors.append(f"{name}: document must be a non-empty object.") - return {} - return document - - -def _validate_declarations(document: dict[str, object], errors: list[str]) -> None: - sources = document.get("sources") - if not isinstance(sources, list) or len(sources) != 1 or not isinstance(sources[0], dict): - errors.append("declarations: exactly one pinned classic source is required.") - else: - source = sources[0] - if source.get("sha256") != EXPECTED_CLASSIC_SOURCE_SHA256: - errors.append("declarations: cepluginsdk.h SHA-256 does not match the reviewed pinned source.") - if source.get("revision") != "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37": - errors.append("declarations: classic source revision changed without an explicit catalogue update.") - - exported = document.get("classic_exported_functions") - if not isinstance(exported, dict): - errors.append("declarations: classic_exported_functions is required.") - return - slots = exported.get("slots") - if not isinstance(slots, list): - errors.append("declarations: slots must be an array.") - return - if exported.get("slot_count") != 159 or len(slots) != 159: - errors.append("declarations: the classic ExportedFunctions table must contain exactly 159 slots.") - - group_counts: dict[str, int] = {} - slot_names: set[str] = set() - manifest: list[dict[str, object]] = [] - for index, slot in enumerate(slots): - if not isinstance(slot, dict): - errors.append(f"declarations: slot {index} is not an object.") - continue - symbol = slot.get("symbol") - source = slot.get("source") - declaration = slot.get("declaration") - group = slot.get("group") - if not isinstance(symbol, str) or not symbol: - errors.append(f"declarations: slot {index} has no symbol.") - continue - if symbol in slot_names: - errors.append(f"declarations: duplicate classic slot {symbol!r}.") - slot_names.add(symbol) - if not isinstance(source, dict) or source.get("source_id") != "ce-classic-c-header" or not _valid_locator(source): - errors.append(f"declarations: slot {symbol!r} has no valid pinned source locator.") - if not isinstance(declaration, str) or not declaration.endswith(";"): - errors.append(f"declarations: slot {symbol!r} has no declaration text.") - if group not in EXPECTED_CLASSIC_SLOT_GROUPS: - errors.append(f"declarations: slot {symbol!r} has invalid group {group!r}.") - else: - group_counts[group] = group_counts.get(group, 0) + 1 - if slot.get("slot_width_bits_x64") not in (32, 64): - errors.append(f"declarations: slot {symbol!r} has an invalid x64 slot width.") - if not isinstance(slot.get("indirection"), str) or not isinstance(slot.get("owner"), str): - errors.append(f"declarations: slot {symbol!r} lacks indirection or owner metadata.") - if isinstance(source, dict) and isinstance(declaration, str): - manifest.append({"symbol": symbol, "line": source.get("line_start"), "declaration": declaration}) - - if group_counts != EXPECTED_CLASSIC_SLOT_GROUPS: - errors.append(f"declarations: classic slot groups differ from {EXPECTED_CLASSIC_SLOT_GROUPS!r}.") - digest = hashlib.sha256(json.dumps(manifest, separators=(",", ":"), ensure_ascii=True).encode("utf-8")).hexdigest().upper() - if exported.get("slot_manifest_sha256") != EXPECTED_CLASSIC_SLOT_MANIFEST_SHA256 or digest != EXPECTED_CLASSIC_SLOT_MANIFEST_SHA256: - errors.append("declarations: classic slot manifest no longer matches the reviewed 159-slot baseline.") - required_slots = {"ReadProcessMemory", "GetAddressFromPointer", "GetLuaState", "MainThreadCall", "FixMem"} - missing_slots = required_slots - slot_names - if missing_slots: - errors.append(f"declarations: required classic slots are absent: {sorted(missing_slots)!r}.") - hook_slots = [slot for slot in slots if isinstance(slot, dict) and slot.get("group") == "hookable-pointer-indirect-suffix"] - if any(slot.get("indirection") != "pointer-to-function-pointer-slot" for slot in hook_slots): - errors.append("declarations: every hookable classic slot must remain pointer-to-function-pointer-slot.") - - -def _validate_profiles(document: dict[str, object], errors: list[str]) -> dict[str, dict[str, object]]: - raw_profiles = document.get("profiles") - profiles: dict[str, dict[str, object]] = {} - if not isinstance(raw_profiles, list): - errors.append("host profiles: profiles must be an array.") - return profiles - for profile in raw_profiles: - if not isinstance(profile, dict) or not isinstance(profile.get("id"), str): - errors.append("host profiles: every profile needs an id.") - continue - identifier = profile["id"] - if identifier in profiles: - errors.append(f"host profiles: duplicate profile id {identifier!r}.") - continue - profiles[identifier] = profile - host = profile.get("host") - target = profile.get("target") - if not isinstance(host, dict) or host.get("architecture") not in {"x64"}: - errors.append(f"host profiles: {identifier!r} must explicitly state the x64 host architecture.") - if not isinstance(target, dict): - errors.append(f"host profiles: {identifier!r} needs a distinct target object.") - continue - if target.get("architecture") == "same-as-host" or target.get("pointer_width_bits") == "same-as-host": - errors.append(f"host profiles: {identifier!r} illegally infers target facts from the host.") - if "architecture" not in target or "pointer_width_bits" not in target: - errors.append(f"host profiles: {identifier!r} must state target architecture and pointer-width observation state.") - return profiles - - -def _validate_capabilities(document: dict[str, object], profiles: dict[str, dict[str, object]], repository_root: Path, - errors: list[str]) -> dict[str, dict[str, object]]: - raw_capabilities = document.get("capabilities") - capabilities: dict[str, dict[str, object]] = {} - if not isinstance(raw_capabilities, list): - errors.append("capabilities: capabilities must be an array.") - return capabilities - for capability in raw_capabilities: - if not isinstance(capability, dict): - errors.append("capabilities: each entry must be an object.") - continue - identifier = capability.get("id") - if not isinstance(identifier, str) or not identifier: - errors.append("capabilities: each entry needs a non-empty id.") - continue - if identifier in capabilities: - errors.append(f"capabilities: duplicate id {identifier!r}.") - continue - capabilities[identifier] = capability - absent = REQUIRED_CAPABILITY_FIELDS - capability.keys() - if absent: - errors.append(f"capabilities: {identifier!r} is missing required fields {sorted(absent)!r}.") - interop = capability.get("interop") - if not isinstance(interop, dict) or REQUIRED_INTEROP_FIELDS - interop.keys(): - errors.append(f"capabilities: {identifier!r} has incomplete interop metadata.") - ownership = capability.get("ownership") - if not isinstance(ownership, dict) or REQUIRED_OWNERSHIP_FIELDS - ownership.keys(): - errors.append(f"capabilities: {identifier!r} has incomplete ownership metadata.") - if capability.get("availability") not in ALLOWED_AVAILABILITY: - errors.append(f"capabilities: {identifier!r} has unsupported availability state.") - if capability.get("qualification") not in ALLOWED_QUALIFICATION: - errors.append(f"capabilities: {identifier!r} has unsupported qualification state.") - references = capability.get("declaration_refs") - if not isinstance(references, list) or not references: - errors.append(f"capabilities: {identifier!r} needs at least one declaration reference.") - else: - for reference in references: - if not isinstance(reference, dict) or not _valid_locator(reference): - errors.append(f"capabilities: {identifier!r} has an invalid declaration locator.") - continue - path = reference.get("path") - if isinstance(path, str) and not path.startswith("Cheat Engine/") and not (repository_root / path).is_file(): - errors.append(f"capabilities: {identifier!r} references missing SDK source {path!r}.") - profile_ids = capability.get("profile_ids") - if not isinstance(profile_ids, list): - errors.append(f"capabilities: {identifier!r} profile_ids must be an array.") - continue - for profile_id in profile_ids: - if profile_id not in profiles: - errors.append(f"capabilities: {identifier!r} references unknown profile {profile_id!r}.") - if capability.get("qualification") == "live-qualified": - errors.append(f"capabilities: {identifier!r} cannot claim live qualification without a reviewed capture profile.") - if capability.get("availability") == "planned" and profile_ids: - errors.append(f"capabilities: planned {identifier!r} must not claim a host profile.") - if capability.get("layer") in {"memory-scan", "target-memory"} and capability.get("availability") == "mapped": - for profile_id in profile_ids: - target = profiles[profile_id].get("target") - if isinstance(target, dict) and target.get("architecture") not in {"x64", "x86", "arm64", "arm32"}: - errors.append(f"capabilities: target-dependent {identifier!r} cannot be mapped against an unobserved target profile.") - - callback_ids = {identifier for identifier, item in capabilities.items() if item.get("layer") == "classic-callback"} - if callback_ids != EXPECTED_CALLBACK_IDS: - errors.append("capabilities: exactly the nine PluginType families and ten callback slots must be catalogued.") - return capabilities - - -def _validate_conflicts(document: dict[str, object], capabilities: dict[str, dict[str, object]], - declarations: dict[str, object], errors: list[str]) -> tuple[dict[str, dict[str, object]], dict[str, str]]: - raw_conflicts = document.get("conflicts") - conflicts: dict[str, dict[str, object]] = {} - states: dict[str, str] = {} - if not isinstance(raw_conflicts, list): - errors.append("conflicts: conflicts must be an array.") - return conflicts, states - for conflict in raw_conflicts: - if not isinstance(conflict, dict) or not isinstance(conflict.get("id"), str): - errors.append("conflicts: every entry needs an id.") - continue - identifier = conflict["id"] - if identifier in conflicts: - errors.append(f"conflicts: duplicate id {identifier!r}.") - continue - conflicts[identifier] = conflict - for required in REQUIRED_CONFLICT_FIELDS: - if required not in conflict: - errors.append(f"conflicts: {identifier!r} is missing {required!r}.") - states[identifier] = _conflict_resolution_state(conflict, identifier, errors) - - evidence = conflict.get("evidence") - if not isinstance(evidence, list) or not evidence: - errors.append(f"conflicts: {identifier!r} needs at least one source locator.") - elif any(not isinstance(locator, dict) or not _valid_locator(locator) for locator in evidence): - errors.append(f"conflicts: {identifier!r} has an invalid source locator.") - - required_availability = conflict.get("required_availability") - if not isinstance(required_availability, str) or required_availability not in ALLOWED_UNRESOLVED_CONFLICT_AVAILABILITY: - errors.append(f"conflicts: {identifier!r} requires an opaque or unavailable availability state.") - if states[identifier] == "unresolved" and conflict.get("blocks_live_qualification") is not True: - errors.append(f"conflicts: unresolved {identifier!r} must block live qualification.") - - affected = conflict.get("affected_capability_ids") - if not isinstance(affected, list): - errors.append(f"conflicts: {identifier!r} affected_capability_ids must be an array.") - continue - for capability_id in affected: - if not isinstance(capability_id, str) or not capability_id: - errors.append(f"conflicts: {identifier!r} affected_capability_ids must contain non-empty strings.") - continue - if capability_id not in capabilities: - errors.append(f"conflicts: {identifier!r} references unknown capability {capability_id!r}.") - continue - capability_conflicts = capabilities[capability_id].get("conflict_ids") - if not isinstance(capability_conflicts, list) or identifier not in capability_conflicts: - errors.append(f"conflicts: {identifier!r} is not reciprocated by capability {capability_id!r}.") - - if identifier == "classic.fixmem-null-host-slot": - _validate_fixmem_conflict_locator(conflict, declarations, errors) - required_conflicts = { - "classic.type0-address-width", - "classic.type3-return-and-width", - "classic.type4-return-shape", - "classic.type6-popup-bool-pointer-width", - "classic.get-address-from-pointer-return-width", - "classic.fixmem-null-host-slot", - } - absent = required_conflicts - conflicts.keys() - if absent: - errors.append(f"conflicts: mandatory historical conflicts are missing: {sorted(absent)!r}.") - return conflicts, states - - -def _conflict_resolution_state(conflict: dict[str, object], identifier: str, errors: list[str]) -> str: - """Return an unresolved-safe state while validating structured conflict resolution metadata.""" - resolution = conflict.get("resolution") - if isinstance(resolution, str): - if resolution.strip(): - return "unresolved" - errors.append(f"conflicts: {identifier!r} needs a non-empty resolution summary.") - return "unresolved" - if not isinstance(resolution, dict) or set(resolution) != {"state", "summary"}: - errors.append(f"conflicts: {identifier!r} resolution must be legacy prose or a state/summary object.") - return "unresolved" - state = resolution.get("state") - summary = resolution.get("summary") - if not isinstance(state, str) or state != "unresolved" or not isinstance(summary, str) or not summary.strip(): - errors.append(f"conflicts: {identifier!r} resolution must declare the unresolved state and a non-empty summary.") - return "unresolved" - - -def _validate_fixmem_conflict_locator(conflict: dict[str, object], declarations: dict[str, object], errors: list[str]) -> None: - """Require the FixMem conflict's C-header evidence to match its canonical slot locator.""" - expected = _classic_slot_locator(declarations, "FixMem") - evidence = conflict.get("evidence") - if expected is None or not isinstance(evidence, list) or not any( - isinstance(locator, dict) and _same_locator(locator, expected) for locator in evidence): - errors.append("conflicts: FixMem C-header evidence must match the FixMem declaration locator.") - - -def _classic_slot_locator(declarations: dict[str, object], symbol: str) -> dict[str, object] | None: - """Find the canonical locator for one named classic export slot.""" - exported = declarations.get("classic_exported_functions") - if not isinstance(exported, dict): - return None - slots = exported.get("slots") - if not isinstance(slots, list): - return None - for slot in slots: - if isinstance(slot, dict) and slot.get("symbol") == symbol and isinstance(slot.get("source"), dict): - return slot["source"] - return None - - -def _same_locator(left: dict[str, object], right: dict[str, object]) -> bool: - """Compare the source identity and inclusive range of two source locators.""" - return all(left.get(field) == right.get(field) for field in ("source_id", "path", "line_start", "line_end")) - - -def _validate_conflicted_capabilities(capabilities: dict[str, dict[str, object]], conflicts: dict[str, dict[str, object]], - conflict_states: dict[str, str], errors: list[str]) -> None: - for capability_id, capability in capabilities.items(): - conflict_ids = capability.get("conflict_ids") - if not isinstance(conflict_ids, list): - continue - for conflict_id in conflict_ids: - conflict = conflicts.get(conflict_id) - if conflict is None: - errors.append(f"capabilities: {capability_id!r} references unknown conflict {conflict_id!r}.") - continue - affected_capability_ids = conflict.get("affected_capability_ids") - if isinstance(affected_capability_ids, list) and capability_id not in affected_capability_ids: - errors.append(f"capabilities: {capability_id!r} is not listed by conflict {conflict_id!r}.") - if conflict_states.get(conflict_id) == "unresolved" and capability.get("availability") != conflict.get("required_availability"): - errors.append(f"capabilities: unresolved conflict {conflict_id!r} requires {conflict.get('required_availability')!r} availability for {capability_id!r}.") - if conflict_states.get(conflict_id) == "unresolved" and capability.get("qualification") == "live-qualified": - errors.append(f"capabilities: {capability_id!r} cannot be live-qualified while {conflict_id!r} is unresolved.") - - -def _validate_examples(document: dict[str, object], capabilities: dict[str, dict[str, object]], errors: list[str]) -> None: - examples = document.get("examples") - if not isinstance(examples, list) or not examples: - errors.append("capabilities: at least one source-indexed example is required.") - return - for example in examples: - if not isinstance(example, dict) or example.get("capability_id") not in capabilities: - errors.append("capabilities: every example must reference a known capability.") - - -def _validate_advanced_families(document: dict[str, object], repository_root: Path, errors: list[str]) -> None: - families = document.get("families") - if not isinstance(families, list): - errors.append("advanced families: families must be an array.") - return - - identifiers: set[str] = set() - for family in families: - if not isinstance(family, dict): - errors.append("advanced families: every family must be an object.") - continue - identifier = family.get("id") - if not isinstance(identifier, str) or not identifier: - errors.append("advanced families: every family needs a non-empty id.") - continue - if identifier in identifiers: - errors.append(f"advanced families: duplicate id {identifier!r}.") - identifiers.add(identifier) - absent = REQUIRED_ADVANCED_FAMILY_FIELDS - family.keys() - if absent: - errors.append(f"advanced families: {identifier!r} is missing required fields {sorted(absent)!r}.") - - owner = family.get("owner") - if not isinstance(owner, dict) or REQUIRED_ADVANCED_OWNER_FIELDS - owner.keys() or any( - not isinstance(value, str) or not value for value in owner.values()): - errors.append(f"advanced families: {identifier!r} needs non-empty SDK and Client ownership statements.") - scope = family.get("scope") - if not isinstance(scope, dict) or REQUIRED_ADVANCED_SCOPE_FIELDS - scope.keys(): - errors.append(f"advanced families: {identifier!r} has incomplete scope metadata.") - elif scope.get("authorization") != "local-authorized-process-only" or scope.get("policy") != "explicit-opt-in-required": - errors.append(f"advanced families: {identifier!r} must preserve local authorization and explicit policy opt-in.") - - for field in ("host_prerequisites", "privilege_requirements", "failure_modes"): - value = family.get(field) - if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item for item in value): - errors.append(f"advanced families: {identifier!r} needs a non-empty {field} list.") - inputs_results_cleanup = family.get("inputs_results_cleanup") - if not isinstance(inputs_results_cleanup, dict) or REQUIRED_ADVANCED_INPUT_RESULT_CLEANUP_FIELDS - inputs_results_cleanup.keys() or any( - not isinstance(value, str) or not value for value in inputs_results_cleanup.values()): - errors.append(f"advanced families: {identifier!r} needs inputs, result, and cleanup boundaries.") - - if not isinstance(family.get("evidence_gap"), str) or not family.get("evidence_gap"): - errors.append(f"advanced families: {identifier!r} needs an explicit evidence gap.") - source_status = family.get("source_status") - references = family.get("source_refs") - if source_status not in {"pinned-call-path-located", "pinned-call-path-not-located"}: - errors.append(f"advanced families: {identifier!r} has an invalid source status.") - if not isinstance(references, list): - errors.append(f"advanced families: {identifier!r} source_refs must be an array.") - elif source_status == "pinned-call-path-located" and not references: - errors.append(f"advanced families: {identifier!r} must retain a pinned source locator.") - elif source_status == "pinned-call-path-not-located" and references: - errors.append(f"advanced families: {identifier!r} cannot attach a locator it says was not located.") - for reference in references if isinstance(references, list) else []: - if not isinstance(reference, dict) or not _valid_locator(reference): - errors.append(f"advanced families: {identifier!r} has an invalid source locator.") - continue - path = reference.get("path") - if isinstance(path, str) and not path.startswith("Cheat Engine/") and not (repository_root / path).is_file(): - errors.append(f"advanced families: {identifier!r} references missing SDK source {path!r}.") - - dependencies = family.get("dependencies") - if not isinstance(dependencies, list) or any(not isinstance(item, str) or not item for item in dependencies): - errors.append(f"advanced families: {identifier!r} dependencies must be a string array.") - axes = family.get("support_axes") - if not isinstance(axes, dict) or set(axes) != REQUIRED_ADVANCED_SUPPORT_AXES: - errors.append(f"advanced families: {identifier!r} must retain all independent support axes.") - else: - for axis, value in axes.items(): - if not isinstance(value, dict) or not isinstance(value.get("state"), str) or not isinstance(value.get("requirement"), str) or not value["requirement"]: - errors.append(f"advanced families: {identifier!r} axis {axis!r} needs a state and requirement.") - elif value["state"] not in ALLOWED_ADVANCED_AXIS_STATES: - errors.append(f"advanced families: {identifier!r} cannot satisfy {axis!r} before independent review.") - gates = family.get("qualification_gates") - if not isinstance(gates, dict) or set(gates) != REQUIRED_ADVANCED_QUALIFICATION_GATES or any( - not isinstance(value, list) or not value or any(not isinstance(item, str) or not item for item in value) - for value in gates.values()): - errors.append(f"advanced families: {identifier!r} needs non-empty fixture, live, negative, and cleanup gates.") - decision = family.get("adoption_decision") - if not isinstance(decision, dict) or decision.get("state") != "deferred" or decision.get("implementation_issue") != "not-created" or not isinstance(decision.get("reason"), str) or not decision["reason"]: - errors.append(f"advanced families: {identifier!r} needs its own deferred adoption decision.") - availability = family.get("availability") - if not isinstance(availability, str) or availability not in ALLOWED_ADVANCED_FAMILY_AVAILABILITY or family.get("qualification") != "not-qualified" or family.get("profile_ids") != []: - errors.append(f"advanced families: {identifier!r} remains unavailable and unqualified without a profile.") - - if identifiers != EXPECTED_ADVANCED_FAMILY_IDS: - errors.append("advanced families: the independently gated family set does not match the reviewed SDK-020 partition.") - - -def _valid_locator(locator: dict[str, object]) -> bool: - source_id = locator.get("source_id") - path = locator.get("path") - line_start = locator.get("line_start") - line_end = locator.get("line_end") - return isinstance(source_id, str) and bool(source_id.strip()) and isinstance(path, str) and bool(path.strip()) and type(line_start) is int and type(line_end) is int and line_start > 0 and line_end >= line_start - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path.cwd(), help="Repository root containing the catalogue.") - arguments = parser.parse_args() - root = arguments.root.resolve() - errors = validate_catalog(load_catalog(root), root) - if errors: - print("CE extension-surface catalogue validation failed:", file=sys.stderr) - for error in errors: - print(f"- {error}", file=sys.stderr) - return 1 - print("CE extension-surface catalogue validation passed.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/eng/Validate-EngineeringManifest.py b/eng/Validate-EngineeringManifest.py deleted file mode 100644 index 6c890341..00000000 --- a/eng/Validate-EngineeringManifest.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Validate the checked-in engineering roadmap without contacting GitHub.""" - -from __future__ import annotations - -import json -import re -import sys -from collections.abc import Iterable -from pathlib import Path -from typing import Any - - -MANIFEST_RELATIVE_PATH = Path("documentations/engineering/backlog.json") -ROADMAP_RELATIVE_PATH = Path("ROADMAP.md") -ARCHIVE_RECONCILIATION_RELATIVE_PATH = Path( - "documentations/engineering/ARCHIVE_RECONCILIATION.md" -) -EXPECTED_SCHEMA_VERSION = 2 -EXPECTED_ARCHIVE_SHA256 = "fc1f178916ec14fb993e405018112ddedd8dc316a86aa6fe9251b42c09547802" -REQUIRED_ITEM_FIELDS = ( - "id", - "repository_key", - "kind", - "title", - "parent", - "children", - "blocked_by", - "blocks", - "acceptance", - "sources", - "path", - "body_template", -) - - -def read_manifest(repository_root: Path) -> object: - """Read the versioned manifest and report malformed JSON as a validation error.""" - manifest_path = repository_root / MANIFEST_RELATIVE_PATH - try: - return json.loads(manifest_path.read_text(encoding="utf-8")) - except FileNotFoundError as error: - raise ValueError(f"Missing manifest: {MANIFEST_RELATIVE_PATH.as_posix()}") from error - except json.JSONDecodeError as error: - raise ValueError(f"Invalid manifest JSON: {error}") from error - - -def validate_manifest(manifest: object, repository_root: Path) -> list[str]: - """Return every deterministic manifest error; this verifier intentionally stays offline.""" - if not isinstance(manifest, dict): - return ["Manifest root must be an object."] - - errors: list[str] = [] - if manifest.get("schema_version") != EXPECTED_SCHEMA_VERSION: - errors.append( - f"schema_version must be {EXPECTED_SCHEMA_VERSION}, got {manifest.get('schema_version')!r}." - ) - - items = manifest.get("items") - if not isinstance(items, list) or not items: - return [*errors, "items must be a non-empty array."] - - item_by_id = collect_items(items, errors) - external_reference_ids = collect_external_references(manifest, errors) - validate_distinct_reference_ids(item_by_id, external_reference_ids, errors) - validate_archive_context(manifest, repository_root, errors) - validate_items(item_by_id, external_reference_ids, repository_root, errors) - validate_dependency_graph(item_by_id, external_reference_ids, errors) - validate_roadmap_and_reconciliation(item_by_id, repository_root, errors) - return errors - - -def collect_items(items: Iterable[Any], errors: list[str]) -> dict[str, dict[str, Any]]: - item_by_id: dict[str, dict[str, Any]] = {} - for index, item in enumerate(items): - if not isinstance(item, dict): - errors.append(f"items[{index}] must be an object.") - continue - - identifier = item.get("id") - if not isinstance(identifier, str) or not identifier: - errors.append(f"items[{index}].id must be a non-empty string.") - continue - if identifier in item_by_id: - errors.append(f"Duplicate planning ID: {identifier}.") - continue - item_by_id[identifier] = item - return item_by_id - - -def collect_external_references(manifest: dict[str, Any], errors: list[str]) -> set[str]: - references = manifest.get("external_references") - if not isinstance(references, list): - errors.append("external_references must be an array.") - return set() - - identifiers: set[str] = set() - for index, reference in enumerate(references): - if not isinstance(reference, dict): - errors.append(f"external_references[{index}] must be an object.") - continue - identifier = reference.get("id") - repository = reference.get("repository") - role = reference.get("role") - if not isinstance(identifier, str) or not identifier: - errors.append(f"external_references[{index}].id must be a non-empty string.") - continue - if identifier in identifiers: - errors.append(f"Duplicate external planning ID: {identifier}.") - identifiers.add(identifier) - if repository != "CheatEngineNet/CheatEngine.Client": - errors.append( - f"External reference {identifier} must identify CheatEngineNet/CheatEngine.Client." - ) - if not isinstance(role, str) or not role: - errors.append(f"External reference {identifier} must have a non-empty role.") - return identifiers - - -def validate_distinct_reference_ids( - item_by_id: dict[str, dict[str, Any]], - external_reference_ids: set[str], - errors: list[str], -) -> None: - for identifier in sorted(item_by_id.keys() & external_reference_ids): - errors.append(f"Planning ID {identifier} cannot be both local and external.") - - -def validate_archive_context( - manifest: dict[str, Any], repository_root: Path, errors: list[str] -) -> None: - review_context = manifest.get("review_context") - if not isinstance(review_context, dict): - errors.append("review_context must be an object.") - return - - archive = review_context.get("archive") - if not isinstance(archive, dict): - errors.append("review_context.archive must be an object.") - elif archive.get("sha256") != EXPECTED_ARCHIVE_SHA256: - errors.append("review_context.archive.sha256 does not match the reviewed package receipt.") - - reconciliation = review_context.get("reconciliation_document") - if reconciliation != ARCHIVE_RECONCILIATION_RELATIVE_PATH.as_posix(): - errors.append("review_context.reconciliation_document must point to ARCHIVE_RECONCILIATION.md.") - elif not (repository_root / reconciliation).is_file(): - errors.append(f"Missing reconciliation document: {reconciliation}.") - - -def validate_items( - item_by_id: dict[str, dict[str, Any]], - external_reference_ids: set[str], - repository_root: Path, - errors: list[str], -) -> None: - for identifier, item in item_by_id.items(): - for field in REQUIRED_ITEM_FIELDS: - if field not in item: - errors.append(f"{identifier} is missing required field '{field}'.") - - if item.get("repository_key") != "sdk": - errors.append(f"{identifier}.repository_key must be 'sdk'.") - validate_parent_and_children(identifier, item, item_by_id, errors) - blocked_by = validate_string_collection(identifier, item, "blocked_by", errors) - blocks = validate_string_collection(identifier, item, "blocks", errors) - validate_string_collection(identifier, item, "acceptance", errors) - validate_string_collection(identifier, item, "sources", errors) - validate_work_item_file(identifier, item, repository_root, errors) - - for dependency in blocked_by: - if dependency not in item_by_id and dependency not in external_reference_ids: - errors.append(f"{identifier}.blocked_by references undeclared ID {dependency}.") - for dependent in blocks: - if dependent not in item_by_id and dependent not in external_reference_ids: - errors.append(f"{identifier}.blocks references undeclared ID {dependent}.") - - -def validate_parent_and_children( - identifier: str, item: dict[str, Any], item_by_id: dict[str, dict[str, Any]], errors: list[str] -) -> None: - parent = item.get("parent") - if parent is not None and (not isinstance(parent, str) or not parent): - errors.append(f"{identifier}.parent must be a non-empty string or null.") - elif parent is not None and parent not in item_by_id: - errors.append(f"{identifier}.parent references undeclared ID {parent}.") - elif parent is not None and identifier not in safe_string_collection( - item_by_id[parent], "children" - ): - errors.append(f"{identifier}.parent and {parent}.children disagree.") - - children = validate_string_collection(identifier, item, "children", errors) - for child in children: - child_item = item_by_id.get(child) - if child_item is None: - errors.append(f"{identifier}.children references undeclared ID {child}.") - elif child_item.get("parent") != identifier: - errors.append(f"{identifier}.children and {child}.parent disagree.") - - -def validate_string_collection( - identifier: str, item: dict[str, Any], field: str, errors: list[str] -) -> list[str]: - values = item.get(field) - if not isinstance(values, list): - errors.append(f"{identifier}.{field} must be an array.") - return [] - strings: list[str] = [] - for value in values: - if not isinstance(value, str) or not value: - errors.append(f"{identifier}.{field} must contain only non-empty strings.") - return [] - strings.append(value) - if len(strings) != len(set(strings)): - errors.append(f"{identifier}.{field} contains duplicate IDs.") - return strings - - -def safe_string_collection(item: dict[str, Any], field: str) -> list[str]: - """Return a collection only when prior validation could safely iterate it.""" - values = item.get(field) - if not isinstance(values, list): - return [] - - strings: list[str] = [] - for value in values: - if not isinstance(value, str) or not value: - return [] - strings.append(value) - return strings - - -def validate_work_item_file( - identifier: str, item: dict[str, Any], repository_root: Path, errors: list[str] -) -> None: - relative_path = item.get("path") - if not isinstance(relative_path, str) or not relative_path: - errors.append(f"{identifier}.path must be a non-empty string.") - return - work_items_root = (repository_root / "documentations/engineering/work-items").resolve() - item_path = (repository_root / relative_path).resolve() - try: - item_path.relative_to(work_items_root) - except ValueError: - errors.append( - f"{identifier}.path must resolve under documentations/engineering/work-items/." - ) - return - - if not item_path.is_file(): - errors.append(f"{identifier}.path does not exist: {relative_path}.") - return - contents = item_path.read_text(encoding="utf-8") - marker = f"" - heading = f"## {identifier} — {item.get('title', '')}" - if marker not in contents: - errors.append(f"{relative_path} is missing {marker}.") - if heading not in contents: - errors.append(f"{relative_path} does not match the manifest title for {identifier}.") - - -def validate_dependency_graph( - item_by_id: dict[str, dict[str, Any]], external_reference_ids: set[str], errors: list[str] -) -> None: - declared_external_edges: set[str] = set() - for identifier, item in item_by_id.items(): - for dependent in safe_string_collection(item, "blocks"): - if dependent in external_reference_ids: - declared_external_edges.add(dependent) - elif dependent in item_by_id and identifier not in item_by_id[dependent].get( - "blocked_by", [] - ): - errors.append(f"{identifier}.blocks and {dependent}.blocked_by disagree.") - - for dependency in safe_string_collection(item, "blocked_by"): - if dependency in item_by_id and identifier not in item_by_id[dependency].get( - "blocks", [] - ): - errors.append(f"{identifier}.blocked_by and {dependency}.blocks disagree.") - - unused_external_references = external_reference_ids - declared_external_edges - for identifier in sorted(unused_external_references): - errors.append(f"External reference {identifier} has no SDK blocks edge.") - find_dependency_cycles(item_by_id, errors) - - -def find_dependency_cycles(item_by_id: dict[str, dict[str, Any]], errors: list[str]) -> None: - visiting: set[str] = set() - visited: set[str] = set() - - def visit(identifier: str, trail: list[str]) -> None: - if identifier in visiting: - cycle_start = trail.index(identifier) - cycle = trail[cycle_start:] + [identifier] - errors.append(f"Dependency cycle: {' -> '.join(cycle)}.") - return - if identifier in visited: - return - visiting.add(identifier) - trail.append(identifier) - for dependency in safe_string_collection(item_by_id[identifier], "blocked_by"): - if dependency in item_by_id: - visit(dependency, trail) - trail.pop() - visiting.remove(identifier) - visited.add(identifier) - - for identifier in item_by_id: - visit(identifier, []) - - -def validate_roadmap_and_reconciliation( - item_by_id: dict[str, dict[str, Any]], repository_root: Path, errors: list[str] -) -> None: - roadmap_path = repository_root / ROADMAP_RELATIVE_PATH - if not roadmap_path.is_file(): - errors.append(f"Missing roadmap: {ROADMAP_RELATIVE_PATH.as_posix()}.") - else: - roadmap = roadmap_path.read_text(encoding="utf-8") - for item in item_by_id.values(): - if item.get("kind") in {"roadmap", "epic"}: - continue - relative_path = item.get("path") - if isinstance(relative_path, str) and f"({relative_path})" not in roadmap: - errors.append(f"ROADMAP.md does not link {relative_path}.") - - reconciliation_path = repository_root / ARCHIVE_RECONCILIATION_RELATIVE_PATH - if not reconciliation_path.is_file(): - return - reconciliation = reconciliation_path.read_text(encoding="utf-8") - if EXPECTED_ARCHIVE_SHA256 not in reconciliation.lower(): - errors.append("ARCHIVE_RECONCILIATION.md does not contain the archive SHA-256.") - for number in range(1, 37): - identifier = f"R{number:02d}" - if not contains_trace_identifier(reconciliation, identifier): - errors.append(f"ARCHIVE_RECONCILIATION.md does not trace {identifier}.") - for number in range(1, 81): - identifier = f"T{number:03d}" - if not contains_trace_identifier(reconciliation, identifier): - errors.append(f"ARCHIVE_RECONCILIATION.md does not trace {identifier}.") - - -def contains_trace_identifier(document: str, identifier: str) -> bool: - pattern = rf"(? int: - repository_root = Path(__file__).resolve().parents[1] - try: - manifest = read_manifest(repository_root) - except ValueError as error: - print(f"Engineering manifest validation failed: {error}", file=sys.stderr) - return 1 - - errors = validate_manifest(manifest, repository_root) - if errors: - print("Engineering manifest validation failed:", file=sys.stderr) - for error in errors: - print(f"- {error}", file=sys.stderr) - return 1 - - print( - f"Validated {len(manifest['items'])} work items, " - f"{len(manifest['external_references'])} external references, and the local dependency DAG." - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/eng/tests/test_validate_ce_surface_catalog.py b/eng/tests/test_validate_ce_surface_catalog.py deleted file mode 100644 index 6f3f0a93..00000000 --- a/eng/tests/test_validate_ce_surface_catalog.py +++ /dev/null @@ -1,202 +0,0 @@ -import copy -import importlib.util -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -VALIDATOR_PATH = REPOSITORY_ROOT / "eng" / "Validate-CeSurfaceCatalog.py" -SPECIFICATION = importlib.util.spec_from_file_location("validate_ce_surface_catalog", VALIDATOR_PATH) -if SPECIFICATION is None or SPECIFICATION.loader is None: - raise RuntimeError("Could not load the CE surface catalogue validator.") -VALIDATOR = importlib.util.module_from_spec(SPECIFICATION) -SPECIFICATION.loader.exec_module(VALIDATOR) - - -class CeSurfaceCatalogValidationTests(unittest.TestCase): - def setUp(self) -> None: - self.catalog = VALIDATOR.load_catalog(REPOSITORY_ROOT) - - def assert_valid(self, catalog: dict[str, object]) -> None: - self.assertEqual([], VALIDATOR.validate_catalog(catalog, REPOSITORY_ROOT)) - - def assert_invalid(self, catalog: dict[str, object], expected: str) -> None: - errors = VALIDATOR.validate_catalog(catalog, REPOSITORY_ROOT) - self.assertTrue(any(expected in error for error in errors), errors) - - def conflict(self, catalog: dict[str, object], identifier: str) -> dict[str, object]: - conflicts = catalog[VALIDATOR.CONFLICTS_FILE]["conflicts"] - for conflict in conflicts: - if conflict["id"] == identifier: - return conflict - self.fail(f"Conflict {identifier!r} was not found.") - - def capability(self, catalog: dict[str, object], identifier: str) -> dict[str, object]: - capabilities = catalog[VALIDATOR.CAPABILITIES_FILE]["capabilities"] - for capability in capabilities: - if capability["id"] == identifier: - return capability - self.fail(f"Capability {identifier!r} was not found.") - - def test_committed_catalogue_is_valid(self) -> None: - self.assert_valid(self.catalog) - - def test_empty_catalogue_document_fails(self) -> None: - for document_name in ( - VALIDATOR.DECLARATIONS_FILE, - VALIDATOR.CAPABILITIES_FILE, - VALIDATOR.CONFLICTS_FILE, - VALIDATOR.HOST_PROFILES_FILE, - VALIDATOR.ADVANCED_FAMILIES_FILE): - with self.subTest(document=document_name): - catalog = copy.deepcopy(self.catalog) - catalog[document_name] = {} - self.assert_invalid(catalog, f"{document_name}: document must be a non-empty object") - - def test_removing_a_classic_slot_fails(self) -> None: - catalog = copy.deepcopy(self.catalog) - slots = catalog[VALIDATOR.DECLARATIONS_FILE]["classic_exported_functions"]["slots"] - slots.pop() - self.assert_invalid(catalog, "exactly 159 slots") - - def test_reordering_a_classic_slot_fails_manifest_integrity(self) -> None: - catalog = copy.deepcopy(self.catalog) - slots = catalog[VALIDATOR.DECLARATIONS_FILE]["classic_exported_functions"]["slots"] - slots[0], slots[1] = slots[1], slots[0] - self.assert_invalid(catalog, "manifest no longer matches") - - def test_duplicate_capability_id_fails(self) -> None: - catalog = copy.deepcopy(self.catalog) - capabilities = catalog[VALIDATOR.CAPABILITIES_FILE]["capabilities"] - capabilities.append(copy.deepcopy(capabilities[0])) - self.assert_invalid(catalog, "duplicate id") - - def test_unresolved_conflict_cannot_be_promoted(self) -> None: - catalog = copy.deepcopy(self.catalog) - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["availability"] = "catalogued-only" - self.assert_invalid(catalog, "requires 'opaque' availability") - - def test_unresolved_conflict_legacy_prose_cannot_be_promoted(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["resolution"] = "unresolved; pending exact host proof" - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["availability"] = "catalogued-only" - self.assert_invalid(catalog, "requires 'opaque' availability") - - def test_unresolved_conflict_structured_state_cannot_be_promoted(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["resolution"] = {"state": "unresolved", "summary": "Pending exact host proof."} - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["availability"] = "catalogued-only" - self.assert_invalid(catalog, "requires 'opaque' availability") - - def test_unknown_structured_conflict_state_fails_closed(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["resolution"] = {"state": "resolved", "summary": "Unreviewed promotion."} - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["availability"] = "catalogued-only" - self.assert_invalid(catalog, "resolution must declare the unresolved state") - self.assert_invalid(catalog, "requires 'opaque' availability") - - def test_conflict_capability_link_must_be_bidirectional(self) -> None: - catalog = copy.deepcopy(self.catalog) - capability = self.capability(catalog, "classic.callback.process-watcher") - capability["conflict_ids"] = [] - self.assert_invalid(catalog, "is not reciprocated by capability") - - def test_conflict_affected_capability_ids_non_list_fails_without_exception(self) -> None: - for case, value in ( - ("missing", None), - ("null", None), - ("string", "classic.callback.process-watcher"), - ("object", {})): - with self.subTest(case=case): - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - if case == "missing": - del conflict["affected_capability_ids"] - else: - conflict["affected_capability_ids"] = value - self.assert_invalid(catalog, "affected_capability_ids must be an array") - - def test_conflict_affected_capability_ids_rejects_non_string_entries(self) -> None: - for value in ([], {}): - with self.subTest(value=value): - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["affected_capability_ids"] = [value] - self.assert_invalid(catalog, "affected_capability_ids must contain non-empty strings") - - def test_conflict_required_availability_rejects_unhashable_value(self) -> None: - for value in ([], {}): - with self.subTest(value=value): - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["required_availability"] = value - self.assert_invalid(catalog, "requires an opaque or unavailable availability state") - - def test_fixmem_conflict_locator_must_match_declaration_catalogue(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.fixmem-null-host-slot") - conflict["evidence"][0]["line_start"] = 289 - conflict["evidence"][0]["line_end"] = 289 - self.assert_invalid(catalog, "FixMem C-header evidence must match the FixMem declaration locator") - - def test_conflict_locator_rejects_boolean_line_numbers(self) -> None: - catalog = copy.deepcopy(self.catalog) - conflict = self.conflict(catalog, "classic.type3-return-and-width") - conflict["evidence"][0]["line_start"] = True - conflict["evidence"][0]["line_end"] = True - self.assert_invalid(catalog, "has an invalid source locator") - - def test_target_architecture_cannot_be_inferred_from_host(self) -> None: - catalog = copy.deepcopy(self.catalog) - profiles = catalog[VALIDATOR.HOST_PROFILES_FILE]["profiles"] - profiles[0]["target"]["architecture"] = "same-as-host" - self.assert_invalid(catalog, "illegally infers target facts") - - def test_live_qualification_is_rejected_without_a_reviewed_capture(self) -> None: - catalog = copy.deepcopy(self.catalog) - capabilities = catalog[VALIDATOR.CAPABILITIES_FILE]["capabilities"] - capabilities[0]["qualification"] = "live-qualified" - self.assert_invalid(catalog, "cannot claim live qualification") - - def test_advanced_family_cannot_drop_an_independent_support_axis(self) -> None: - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - del family["support_axes"]["host"] - self.assert_invalid(catalog, "all independent support axes") - - def test_advanced_family_cannot_be_promoted_from_its_ledger(self) -> None: - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - family["availability"] = "mapped" - self.assert_invalid(catalog, "remains unavailable and unqualified") - - def test_advanced_family_availability_rejects_unhashable_value(self) -> None: - for value in ([], {}): - with self.subTest(value=value): - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - family["availability"] = value - self.assert_invalid(catalog, "remains unavailable and unqualified") - - def test_advanced_family_rejects_unknown_support_axis_state(self) -> None: - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - family["support_axes"]["host"]["state"] = "review-complete" - self.assert_invalid(catalog, "cannot satisfy 'host' before independent review") - - def test_advanced_family_requires_its_own_live_gate(self) -> None: - catalog = copy.deepcopy(self.catalog) - family = catalog[VALIDATOR.ADVANCED_FAMILIES_FILE]["families"][0] - family["qualification_gates"]["live"] = [] - self.assert_invalid(catalog, "needs non-empty fixture, live, negative, and cleanup gates") - - -if __name__ == "__main__": - unittest.main() diff --git a/eng/tests/test_validate_engineering_manifest.py b/eng/tests/test_validate_engineering_manifest.py deleted file mode 100644 index 6401f43e..00000000 --- a/eng/tests/test_validate_engineering_manifest.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Regression tests for the offline engineering-manifest verifier.""" - -from __future__ import annotations - -import copy -import importlib.util -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -VALIDATOR_PATH = REPOSITORY_ROOT / "eng/Validate-EngineeringManifest.py" -SPECIFICATION = importlib.util.spec_from_file_location("engineering_manifest", VALIDATOR_PATH) -if SPECIFICATION is None or SPECIFICATION.loader is None: - raise RuntimeError("Could not load the engineering manifest validator.") -VALIDATOR = importlib.util.module_from_spec(SPECIFICATION) -SPECIFICATION.loader.exec_module(VALIDATOR) - - -class EngineeringManifestValidationTests(unittest.TestCase): - def setUp(self) -> None: - self.manifest = VALIDATOR.read_manifest(REPOSITORY_ROOT) - - def test_current_manifest_is_valid(self) -> None: - errors = VALIDATOR.validate_manifest(self.manifest, REPOSITORY_ROOT) - - self.assertEqual([], errors) - - def test_duplicate_planning_id_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["items"].append(copy.deepcopy(manifest["items"][0])) - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("Duplicate planning ID: SDK-PLAN.", errors) - - def test_non_object_manifest_root_is_rejected(self) -> None: - errors = VALIDATOR.validate_manifest([], REPOSITORY_ROOT) - - self.assertEqual(["Manifest root must be an object."], errors) - - def test_invalid_collection_is_reported_without_iteration_failure(self) -> None: - manifest = copy.deepcopy(self.manifest) - sdk_001 = next(item for item in manifest["items"] if item["id"] == "SDK-001") - sdk_001["blocked_by"] = None - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("SDK-001.blocked_by must be an array.", errors) - - def test_missing_external_reference_for_sdk_edge_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["external_references"] = [ - reference - for reference in manifest["external_references"] - if reference["id"] != "CLI-007" - ] - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("SDK-008.blocks references undeclared ID CLI-007.", errors) - - def test_local_id_cannot_be_declared_as_an_external_reference(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["external_references"].append( - { - "id": "SDK-001", - "repository": "CheatEngineNet/CheatEngine.Client", - "role": "invalid test fixture", - } - ) - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("Planning ID SDK-001 cannot be both local and external.", errors) - - def test_inconsistent_reverse_edge_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - sdk_002 = next(item for item in manifest["items"] if item["id"] == "SDK-002") - sdk_002["blocks"].remove("SDK-004") - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("SDK-004.blocked_by and SDK-002.blocks disagree.", errors) - - def test_missing_parent_reciprocity_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - sdk_e01 = next(item for item in manifest["items"] if item["id"] == "SDK-E01") - sdk_e01["children"].remove("SDK-001") - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn("SDK-001.parent and SDK-E01.children disagree.", errors) - - def test_dependency_cycle_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - sdk_001 = next(item for item in manifest["items"] if item["id"] == "SDK-001") - sdk_001["blocked_by"].append("SDK-002") - sdk_002 = next(item for item in manifest["items"] if item["id"] == "SDK-002") - sdk_002["blocks"].append("SDK-001") - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertTrue(any(error.startswith("Dependency cycle:") for error in errors)) - - def test_missing_work_item_file_is_rejected(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["items"][0]["path"] = "documentations/engineering/work-items/missing.md" - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn( - "SDK-PLAN.path does not exist: documentations/engineering/work-items/missing.md.", - errors, - ) - - def test_work_item_path_cannot_escape_the_work_items_directory(self) -> None: - manifest = copy.deepcopy(self.manifest) - manifest["items"][0]["path"] = "documentations/engineering/work-items/../../ROADMAP.md" - - errors = VALIDATOR.validate_manifest(manifest, REPOSITORY_ROOT) - - self.assertIn( - "SDK-PLAN.path must resolve under documentations/engineering/work-items/.", - errors, - ) - - def test_trace_identifiers_require_alphanumeric_token_boundaries(self) -> None: - document = "R010 and T0010 are unrelated; R01 and T001 are traced." - - self.assertTrue(VALIDATOR.contains_trace_identifier(document, "R01")) - self.assertTrue(VALIDATOR.contains_trace_identifier(document, "T001")) - self.assertFalse(VALIDATOR.contains_trace_identifier("R010", "R01")) - self.assertFalse(VALIDATOR.contains_trace_identifier("T0010", "T001")) - - -if __name__ == "__main__": - unittest.main() From babcb0a1f42bb31c7489bcc2970d01a38593bee4 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:07:42 +0200 Subject: [PATCH 006/199] Add the documentation index and placeholder pages Commit 4020a32 removed the documentations/ tree, and the maintainer decided not to restore it (audit F14, ADR-12, SRCREG-06). The pages that still need a destination get one under docs/, rebuilt from the audit rather than restored, and every page carries the header "Recreated 2026-09 from the audit, not the historical documentations/ tree". docs/README.md is the index: it names the audit only by the SHA-256 of its MANIFESTE.md, lists the rebuilt areas, summarises the C0-C4 evidence levels, and records the retired paths (as code spans, never links) with their replacement, so statements that relied on them stay "declared, not recovered" (DeclaredRepo) instead of silently disappearing. It also maps the unresolvable architecture-review scenario ids (R25/T049-T050, R26/T051-T052, R34/T067-T068/T076) to the closest audit scenarios (Q09/Q10, Q19, Q30). Qualification, ABI/NativeAOT and catalogue content arrives with later work, so those pages are placeholders that name their owning work and wave. They exist now so that re-pointed links resolve immediately. The NativeAOT placeholder already states the restriction Microsoft documents for Native AOT libraries: unloading them with FreeLibrary is not supported (https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries). The solution lists the new pages under /docs/, /docs/abi/ and /docs/catalog/; the qualification pages are listed by the qualification work together with its own files. --- CheatEngine.SDK.slnx | 10 +++++ docs/README.md | 60 +++++++++++++++++++++++++++ docs/abi/README.md | 7 ++++ docs/abi/nativeaot-profile.md | 9 ++++ docs/catalog/README.md | 8 ++++ docs/qualification/README.md | 7 ++++ docs/qualification/local-protocol.md | 7 ++++ docs/qualification/support-profile.md | 7 ++++ 8 files changed, 115 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/abi/README.md create mode 100644 docs/abi/nativeaot-profile.md create mode 100644 docs/catalog/README.md create mode 100644 docs/qualification/README.md create mode 100644 docs/qualification/local-protocol.md create mode 100644 docs/qualification/support-profile.md diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 48cc6b3f..aa555b09 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -31,6 +31,16 @@ + + + + + + + + + + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..9ede7bd6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,60 @@ +# CheatEngine.SDK documentation + +> Recreated 2026-09 from the audit, not the historical documentations/ tree. + +This folder holds what the CheatEngineNet audit of 2026-09-22 (`MANIFESTE.md` SHA-256 +`7179b0691d27cba0589b3f5fa00945ddbb7c04b362500df3de30726550f4ff6e`) requires to be distributable with the SDK (ADR-12): +the restrictions, hashes, source references and qualification results that a reader needs to check a compatibility +statement from this repository alone, without access to anyone's private workspace. Every page is rebuilt from the +audit; none is a restored copy of an earlier document. + +## Contents + +| Area | Page | Status | +|--------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|-----------------| +| Qualification: support profiles, Q01–Q48 matrix, receipts of executed runs, local protocol | [qualification/README.md](qualification/README.md) | Placeholder | +| ABI and NativeAOT restrictions | [abi/README.md](abi/README.md), [abi/nativeaot-profile.md](abi/nativeaot-profile.md) | Placeholder | +| Lua surface catalogue, deferred families, four coverage measures | [catalog/README.md](catalog/README.md) | Placeholder | +| Audit traceability | `audit-2026-09-22-traceability.md`, added at the end of the remediation | Not written yet | + +A placeholder page states its scope and the work that replaces it; it records no evidence yet. + +## Evidence levels + +C0 static contract, C1 managed tests, C2 native fixture, C3 exact Cheat Engine host with a loaded plugin, C4 +multi-component (two plugins, target switch). A C1 or C2 result is never presented as host-qualified. Details: +[qualification/README.md](qualification/README.md). + +## Retired documentation + +Commit `4020a32` removed the `documentations/` tree and `native/cheatengine-sdk-lua-bridge/AUDIT.md` on 2026-09-22. They +are **not restored**. Statements that relied on them are **declared, not recovered** (evidence kind `DeclaredRepo`) until +a page listed here re-establishes them with its own evidence. Their removal does not mean that the work they described +never existed; it means that a reader of this repository cannot check it. + +| Old path | Replacement | Note | +|---------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| +| `documentations/CheatEngine.SDK/capability-matrix.md` | [catalog/README.md](catalog/README.md) for per-surface provenance and semantics; [qualification/README.md](qualification/README.md) for executed evidence | The two concerns are separate pages. | +| `documentations/CheatEngine.SDK/SOURCES.md` | [qualification/support-profile.md](qualification/support-profile.md) | Cheat Engine, Lua module and `celua.txt` hashes. | +| `documentations/CheatEngine.SDK/advanced-domains/` | [catalog/README.md](catalog/README.md) | Deferred families. | +| `documentations/CheatEngine.SDK/live-probes/README.md` | [qualification/local-protocol.md](qualification/local-protocol.md) | Recording and redaction rules of exact-host runs. | +| `documentations/CheatEngine.SDK/catalog/*.json` | [catalog/README.md](catalog/README.md) | Rebuilt from scratch, not restored. | +| `documentations/engineering/ADR-006-nativeaot-plugin-loader-profile.md` | [abi/nativeaot-profile.md](abi/nativeaot-profile.md) | F02, Q41 and Q42 restrictions. | +| `documentations/engineering/work-items/SDK-*.md`, `documentations/engineering/backlog.json` | None | [ROADMAP](../ROADMAP.md) keeps the `SDK-0xx` identifiers as plain text. | +| `documentations/engineering/ARCHIVE_RECONCILIATION.md` | [Retired identifiers](#retired-identifiers) | Architecture-review scenario identifiers. | +| `native/cheatengine-sdk-lua-bridge/AUDIT.md` | [Bridge README](../native/cheatengine-sdk-lua-bridge/README.md) | Contract, build and delivery of the Lua protection bridge. | + +## Retired identifiers + +Some READMEs cited architecture-review scenarios such as `R25/T049`. These identifiers come from an archive that is not +in this repository; the retired `ARCHIVE_RECONCILIATION.md` was the only page that listed them. They are replaced by the +qualification scenarios of the audit: + +| Retired identifiers | Scope, as the Hosting and Coexistence READMEs described it | Audit scenario | +|---------------------|----------------------------------------------------------------------------------------------------------------|----------------| +| R25/T049–T050 | Two plugins in one Cheat Engine process: shared or separate SDK assemblies, independent activation and removal | Q09, Q10 | +| R26/T051–T052 | First-thread Lua acquisition, refusal, and cross-plugin worker/main-thread concurrency | Q19 | +| R34/T067–T068/T076 | Target switch and retained allocation/patch ownership | Q30 | + +The mapping follows the scope that the Hosting and Coexistence READMEs describe for each identifier. It names the +closest audit scenario; it is not a transcription of the unrecovered archive. diff --git a/docs/abi/README.md b/docs/abi/README.md new file mode 100644 index 00000000..7a1948a3 --- /dev/null +++ b/docs/abi/README.md @@ -0,0 +1,7 @@ +# ABI + +> Recreated 2026-09 from the audit, not the historical documentations/ tree. + +Classic and managed plugin ABI contracts, slot registry and Lua interop audit. + +Status: placeholder — content arrives with S-ABI (V2) diff --git a/docs/abi/nativeaot-profile.md b/docs/abi/nativeaot-profile.md new file mode 100644 index 00000000..fe9dcf2a --- /dev/null +++ b/docs/abi/nativeaot-profile.md @@ -0,0 +1,9 @@ +# NativeAOT plugin profile + +> Recreated 2026-09 from the audit, not the historical documentations/ tree. + +A NativeAOT plugin is not a supported Cheat Engine profile: unloading Native AOT libraries with `FreeLibrary` is not +supported ([Microsoft](https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries)); restrictions for F02, +Q41 and Q42. + +Status: placeholder — content arrives with S-ABI (V2) diff --git a/docs/catalog/README.md b/docs/catalog/README.md new file mode 100644 index 00000000..fa11ee78 --- /dev/null +++ b/docs/catalog/README.md @@ -0,0 +1,8 @@ +# Lua surface catalogue + +> Recreated 2026-09 from the audit, not the historical documentations/ tree. + +The catalogued Cheat Engine Lua surface, the deferred families and the four coverage measures (catalogued, implemented, +fixture-tested, host-qualified). + +Status: placeholder — content arrives with S-CAT-A (V2) diff --git a/docs/qualification/README.md b/docs/qualification/README.md new file mode 100644 index 00000000..f3f37965 --- /dev/null +++ b/docs/qualification/README.md @@ -0,0 +1,7 @@ +# Qualification + +> Recreated 2026-09 from the audit, not the historical documentations/ tree. + +Support profiles, the Q01–Q48 qualification matrix and the receipts of executed C3/C4 runs. + +Status: placeholder — content arrives with S-QUAL (V1) diff --git a/docs/qualification/local-protocol.md b/docs/qualification/local-protocol.md new file mode 100644 index 00000000..c6f26e94 --- /dev/null +++ b/docs/qualification/local-protocol.md @@ -0,0 +1,7 @@ +# Local qualification protocol + +> Recreated 2026-09 from the audit, not the historical documentations/ tree. + +How an operator runs, records and redacts an exact-host (C3/C4) qualification run and its receipt. + +Status: placeholder — content arrives with S-QUAL (V1) diff --git a/docs/qualification/support-profile.md b/docs/qualification/support-profile.md new file mode 100644 index 00000000..1c42f6d8 --- /dev/null +++ b/docs/qualification/support-profile.md @@ -0,0 +1,7 @@ +# Support profile + +> Recreated 2026-09 from the audit, not the historical documentations/ tree. + +Exact Cheat Engine host, Lua module, `celua.txt` and runtime-configuration identities of each supported profile. + +Status: placeholder — content arrives with S-QUAL (V1) From eab80f227779f404ed50b65b6602eadd498d6199 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:09:08 +0200 Subject: [PATCH 007/199] Re-point links to the retired documentations tree Commit 4020a32 removed documentations/**, and 33 relative links (plus one code-span path to native/cheatengine-sdk-lua-bridge/AUDIT.md) were left pointing at it (audit F14, ADR-12, A09-15, A09-16, A21-22). They are re-pointed, not restored: - ROADMAP: the 24 SDK-0xx work-item links become plain identifiers (titles kept, tables re-padded), and a pointer to the retired- documentation table replaces the sentence that sent readers to live issues: no issue or Project tracks these work items. - The capability-matrix links split into the concerns the audit separates: per-surface provenance and semantics go to the Lua surface catalogue (docs/catalog), executed host evidence to the qualification matrix (docs/qualification, value scans = Q25/Q26). - The source index link goes to the support profile, which records the celua.txt digest; the live-probe templates to the local qualification protocol; ADR-006 to the NativeAOT profile restrictions (docs/abi); SDK-005 to the Q09/Q10 coexistence scenarios; AUDIT.md to the bridge README. - The advanced-domain and structure-family pointers go to the deferred families of the catalogue, without claiming it records them already. Targets that later work fills in are placeholder pages, so every link resolves now. Where a wrap had split "and" / "the" onto its own line in the address-list example, the paragraph is re-flowed. --- ROADMAP.md | 86 ++++++++++--------- exemples/06-value-scans/README.md | 4 +- exemples/07-address-list/README.md | 3 +- exemples/api/README.md | 6 +- exemples/recipes/structures/README.md | 6 +- libs/CheatEngine.SDK.Engine/README.md | 6 +- .../README.md | 4 +- tests/CheatEngine.SDK.LiveProbe/README.md | 3 +- .../README.md | 4 +- .../README.md | 4 +- tests/CheatEngine.SDK.Tests/README.md | 4 +- 11 files changed, 65 insertions(+), 65 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ec8e5e50..6d4bcb3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,10 @@ **Planning baseline: September 21, 2026.** This is an outcome-based plan, not a delivery-date commitment. The initial preparation session was denied GitHub writes; this branch is the later reviewable import. The roadmap describes planned -outcomes, never implementation, package, fixture, or live-host completion. Read the live issue and Project state -separately from this versioned plan. +outcomes, never implementation, package, fixture, or live-host completion. + +The `SDK-0xx` work-item pages were retired on 2026-09-22 and are not restored; the identifiers below are plain text. +See [retired documentation](docs/README.md#retired-documentation). ## Operating boundary @@ -28,81 +30,81 @@ research. Source merged, package shipped, fixture passed and host qualified are Maintain the source-indexed execution baseline without inventing audit coverage. -| Work item | Priority | Prerequisites | -|-----------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------------| -| [SDK-001](documentations/engineering/work-items/SDK-001.md) — Establish source, artifact, and capability provenance | P1 | Refinement and evidence; no declared issue blocker | -| [SDK-002](documentations/engineering/work-items/SDK-002.md) — Inventory the public CE extension surface and host profiles | P1 | SDK-001 | -| [SDK-003](documentations/engineering/work-items/SDK-003.md) — Adopt engineering governance and validate the bootstrap graph | P1 | Refinement and evidence; no declared issue blocker | +| Work item | Priority | Prerequisites | +|-------------------------------------------------------------------------|----------|----------------------------------------------------| +| SDK-001 — Establish source, artifact, and capability provenance | P1 | Refinement and evidence; no declared issue blocker | +| SDK-002 — Inventory the public CE extension surface and host profiles | P1 | SDK-001 | +| SDK-003 — Adopt engineering governance and validate the bootstrap graph | P1 | Refinement and evidence; no declared issue blocker | ### SDK-E02 — ABI, hosting, and deployment qualification Qualify exact host contracts and preserve the supported managed deployment. -| Work item | Priority | Prerequisites | -|--------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| -| [SDK-004](documentations/engineering/work-items/SDK-004.md) — Qualify classic ABI layouts and conflicting signatures | P1 | SDK-002 | -| [SDK-005](documentations/engineering/work-items/SDK-005.md) — Qualify activation admission, shutdown, and plugin coexistence | P1 | SDK-002 | -| [SDK-006](documentations/engineering/work-items/SDK-006.md) — Decide the NativeAOT plugin loader profile without weakening managed support | P2 | SDK-005 | +| Work item | Priority | Prerequisites | +|----------------------------------------------------------------------------------------|----------|---------------| +| SDK-004 — Qualify classic ABI layouts and conflicting signatures | P1 | SDK-002 | +| SDK-005 — Qualify activation admission, shutdown, and plugin coexistence | P1 | SDK-002 | +| SDK-006 — Decide the NativeAOT plugin loader profile without weakening managed support | P2 | SDK-005 | ### SDK-E03 — Authoritative semantic outcomes and Lua registration Expose reusable CE semantics without depending on Client policy. -| Work item | Priority | Prerequisites | -|------------------------------------------------------------------------------------------------------------------------------|----------|------------------| -| [SDK-007](documentations/engineering/work-items/SDK-007.md) — Preserve structured outcomes across Lua and Engine primitives | P1 | SDK-001 | -| [SDK-008](documentations/engineering/work-items/SDK-008.md) — Own built-in runtime, process, symbol, and table Lua contracts | P1 | SDK-007, SDK-002 | -| [SDK-009](documentations/engineering/work-items/SDK-009.md) — Return ownership-aware Lua registration leases | P1 | SDK-007, SDK-012 | +| Work item | Priority | Prerequisites | +|--------------------------------------------------------------------------|----------|------------------| +| SDK-007 — Preserve structured outcomes across Lua and Engine primitives | P1 | SDK-001 | +| SDK-008 — Own built-in runtime, process, symbol, and table Lua contracts | P1 | SDK-007, SDK-002 | +| SDK-009 — Return ownership-aware Lua registration leases | P1 | SDK-007, SDK-012 | ### SDK-E04 — Runtime identity and resource ownership Make runtime/target authority and exception-safe cleanup reusable for every SDK consumer. -| Work item | Priority | Prerequisites | -|-----------------------------------------------------------------------------------------------------------------------------------|----------|------------------| -| [SDK-010](documentations/engineering/work-items/SDK-010.md) — Establish authoritative target identity for effectful operations | P1 | SDK-007, SDK-002 | -| [SDK-011](documentations/engineering/work-items/SDK-011.md) — Make resource ownership handoff and cleanup exception-safe | P1 | SDK-010, SDK-007 | -| [SDK-012](documentations/engineering/work-items/SDK-012.md) — Qualify shared Lua state, reset, and protected operation boundaries | P1 | SDK-002, SDK-005 | +| Work item | Priority | Prerequisites | +|-------------------------------------------------------------------------------|----------|------------------| +| SDK-010 — Establish authoritative target identity for effectful operations | P1 | SDK-007, SDK-002 | +| SDK-011 — Make resource ownership handoff and cleanup exception-safe | P1 | SDK-010, SDK-007 | +| SDK-012 — Qualify shared Lua state, reset, and protected operation boundaries | P1 | SDK-002, SDK-005 | ### SDK-E05 — Memory and scanning primitives Provide qualified target reads, AOB outcomes, and scan-session ownership. -| Work item | Priority | Prerequisites | -|----------------------------------------------------------------------------------------------------------------------------------|----------|------------------| -| [SDK-013](documentations/engineering/work-items/SDK-013.md) — Qualify target memory, pointer width, and bounded buffer contracts | P1 | SDK-007, SDK-010 | -| [SDK-014](documentations/engineering/work-items/SDK-014.md) — Separate AOB absence, errors, and execution bounds | P1 | SDK-007, SDK-013 | -| [SDK-015](documentations/engineering/work-items/SDK-015.md) — Qualify the existing value-scan session factory | P2 | SDK-011, SDK-013 | +| Work item | Priority | Prerequisites | +|------------------------------------------------------------------------------|----------|------------------| +| SDK-013 — Qualify target memory, pointer width, and bounded buffer contracts | P1 | SDK-007, SDK-010 | +| SDK-014 — Separate AOB absence, errors, and execution bounds | P1 | SDK-007, SDK-013 | +| SDK-015 — Qualify the existing value-scan session factory | P2 | SDK-011, SDK-013 | ### SDK-E06 — Instruction, patch, and debugger contracts Separate instruction processing, target mutation and immediate callback decisions. -| Work item | Priority | Prerequisites | -|---------------------------------------------------------------------------------------------------------------------------------|----------|---------------------------| -| [SDK-016](documentations/engineering/work-items/SDK-016.md) — Qualify assembly and disassembly contracts by instruction profile | P2 | SDK-004, SDK-013 | -| [SDK-017](documentations/engineering/work-items/SDK-017.md) — Qualify Auto Assembler patch application and disable ownership | P1 | SDK-011, SDK-012 | -| [SDK-018](documentations/engineering/work-items/SDK-018.md) — Define synchronous debugger callback and continuation ownership | P2 | SDK-004, SDK-005, SDK-010 | +| Work item | Priority | Prerequisites | +|-----------------------------------------------------------------------------|----------|---------------------------| +| SDK-016 — Qualify assembly and disassembly contracts by instruction profile | P2 | SDK-004, SDK-013 | +| SDK-017 — Qualify Auto Assembler patch application and disable ownership | P1 | SDK-011, SDK-012 | +| SDK-018 — Define synchronous debugger callback and continuation ownership | P2 | SDK-004, SDK-005, SDK-010 | ### SDK-E07 — Record commands and optional capability families Deliver exact record semantics early and keep unrelated advanced families independently gated. -| Work item | Priority | Prerequisites | -|----------------------------------------------------------------------------------------------------------------------------------------|----------|------------------| -| [SDK-019](documentations/engineering/work-items/SDK-019.md) — Qualify timer and hotkey subscription ownership | P2 | SDK-005, SDK-012 | -| [SDK-020](documentations/engineering/work-items/SDK-020.md) — Partition advanced capability research into independently gated families | P3 | SDK-002, SDK-010 | -| [SDK-021](documentations/engineering/work-items/SDK-021.md) — Expose typed record and symbol mutation ownership | P1 | SDK-007, SDK-012 | +| Work item | Priority | Prerequisites | +|------------------------------------------------------------------------------------|----------|------------------| +| SDK-019 — Qualify timer and hotkey subscription ownership | P2 | SDK-005, SDK-012 | +| SDK-020 — Partition advanced capability research into independently gated families | P3 | SDK-002, SDK-010 | +| SDK-021 — Expose typed record and symbol mutation ownership | P1 | SDK-007, SDK-012 | ### SDK-E08 — Generation, artifacts, and ecosystem conformance Make published artifacts and generated consumers match the qualified source contracts. -| Work item | Priority | Prerequisites | -|---------------------------------------------------------------------------------------------------------------------------------|----------|------------------------------------------------------| -| [SDK-022](documentations/engineering/work-items/SDK-022.md) — Validate generated bindings and marshalling in packed consumers | P1 | SDK-007, SDK-009 | -| [SDK-023](documentations/engineering/work-items/SDK-023.md) — Publish a traceable minimum contract artifact for Client adoption | P1 | SDK-008, SDK-009, SDK-010, SDK-011, SDK-021, SDK-022 | -| [SDK-024](documentations/engineering/work-items/SDK-024.md) — Establish release qualification and performance evidence | P2 | SDK-023, SDK-005 | +| Work item | Priority | Prerequisites | +|-----------------------------------------------------------------------------|----------|------------------------------------------------------| +| SDK-022 — Validate generated bindings and marshalling in packed consumers | P1 | SDK-007, SDK-009 | +| SDK-023 — Publish a traceable minimum contract artifact for Client adoption | P1 | SDK-008, SDK-009, SDK-010, SDK-011, SDK-021, SDK-022 | +| SDK-024 — Establish release qualification and performance evidence | P2 | SDK-023, SDK-005 | ## Execution notes diff --git a/exemples/06-value-scans/README.md b/exemples/06-value-scans/README.md index 5295b2c8..35cce7c2 100644 --- a/exemples/06-value-scans/README.md +++ b/exemples/06-value-scans/README.md @@ -110,8 +110,8 @@ state transitions, pre-call cancellation, bounded copying, and stale runtime/tar expose a live value-scan capability, the vertical slice still must record an isolated, opt-in CE 7.7 x64 probe covering success, failure, ordered cleanup, cancellation while a scan is in progress, disable/re-enable and target changes. -The [capability matrix](../../documentations/CheatEngine.SDK/capability-matrix.md) tracks that proof. Until then, use -typed target-memory APIs for scalar reads/writes and `AobScanner` for the ownership-proven AOB result list from the +The [qualification matrix](../../docs/qualification/README.md) (scenarios Q25 and Q26) tracks that proof. Until then, +use typed target-memory APIs for scalar reads/writes and `AobScanner` for the ownership-proven AOB result list from the high-level Client; reserve `MemoryScanSessions.TryCreate` for a deliberately authorized, source-backed SDK experiment. ## Before you move on diff --git a/exemples/07-address-list/README.md b/exemples/07-address-list/README.md index c9c8c361..5c844299 100644 --- a/exemples/07-address-list/README.md +++ b/exemples/07-address-list/README.md @@ -152,8 +152,7 @@ not as an opportunity to retain a borrowed record. ## Evidence and scope This slice is sourced from the exact CE `7.7.0.10621` x64 `celua.txt` fixture (digest recorded in the -[source index](../../documentations/CheatEngine.SDK/SOURCES.md)): `getAddressList`, the `Addresslist` class, and -the +[support profile](../../docs/qualification/support-profile.md)): `getAddressList`, the `Addresslist` class, and the `MemoryRecord` members used above. Its protected-call tests exercise the managed boundary; they do not prove a live CE GUI thread contract. The classic plugin callback type 0 record is a separate ABI concern and is not this object API. diff --git a/exemples/api/README.md b/exemples/api/README.md index ac298ec4..cc0f0205 100644 --- a/exemples/api/README.md +++ b/exemples/api/README.md @@ -328,7 +328,7 @@ returns the Cheat Engine name as UTF-8 (empty for an undefined value or a flag c The normal public API is organized by domain; raw Lua names stay inside the binding layer. Every slice distinguishes a disabled plugin (a lifecycle error) from a CE-reported failure, an unavailable global, protected Lua failure, or an invalid marshalled result. Its source, CE version, architecture, thread requirement, ownership, and normal return -semantics belong in the [capability matrix](../../documentations/CheatEngine.SDK/capability-matrix.md). +semantics belong in the [Lua surface catalogue](../../docs/catalog/README.md). | Domain | Public direction | Ownership and thread boundary | |--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -344,8 +344,8 @@ semantics belong in the [capability matrix](../../documentations/CheatEngine.SDK The owned `AutoAssemblerPatch` is the low-level primitive; high-level Auto Assembler capabilities, arbitrary execution/injection, debugger, DBK/DBVM, UI/D3D, network, and SQL domains remain intentionally opt-in and outside the -ordinary API path until they have a capability, authorization, lifetime, rollback, and live-test contract. See -the [advanced-domain boundaries](../../documentations/CheatEngine.SDK/advanced-domains/README.md). +ordinary API path until they have a capability, authorization, lifetime, rollback, and live-test contract. Their +dispositions belong to the deferred families of the [Lua surface catalogue](../../docs/catalog/README.md). ## Threads diff --git a/exemples/recipes/structures/README.md b/exemples/recipes/structures/README.md index b9f3dfd6..5c73d09e 100644 --- a/exemples/recipes/structures/README.md +++ b/exemples/recipes/structures/README.md @@ -4,7 +4,7 @@ **A deliberately deferred recipe: do not manufacture ownership for Cheat Engine structures.** -**Level** `Advanced` · **Time** `5 min` · **Needs** the Engine capability matrix +**Level** `Advanced` · **Time** `5 min` · **Needs** the Lua surface catalogue [Examples index](../../README.md) · [Recipes](../README.md) @@ -50,8 +50,8 @@ the exact CE build: | Lifetime | A factory-issued `Owned` only if deterministic destruction is proven; otherwise a borrowed handle or no API | | Tests | Fixture tests for stack/cleanup plus an isolated, opt-in CE 7.7 live probe | -That work belongs to the evidence and capability process, not to a recipe that guesses from an object pointer. The -[capability matrix](../../../documentations/CheatEngine.SDK/capability-matrix.md) is the current source of truth. +That work belongs to the evidence and capability process, not to a recipe that guesses from an object pointer. Its +disposition belongs to the deferred families of the [Lua surface catalogue](../../../docs/catalog/README.md). ## Before you move on diff --git a/libs/CheatEngine.SDK.Engine/README.md b/libs/CheatEngine.SDK.Engine/README.md index 2760c175..ead1d836 100644 --- a/libs/CheatEngine.SDK.Engine/README.md +++ b/libs/CheatEngine.SDK.Engine/README.md @@ -51,9 +51,9 @@ host has the same contract. | `Errors` | `EngineException` and stable subclasses | Separates expected operation failure, global absence, Lua failure, binding violation and marshalling violation instead of exposing a raw Lua stack error as the public Engine contract. | The per-capability provenance, minimum CE version, architecture, thread, ownership and return semantics belong to the -versioned [capability matrix](../../documentations/CheatEngine.SDK/capability-matrix.md). Fixture tests validate -managed behavior and the pinned Lua fixture; opt-in live evidence is recorded separately and is not implied by these -wrappers. +[Lua surface catalogue](../../docs/catalog/README.md); executed host evidence belongs to the +[qualification matrix](../../docs/qualification/README.md). Fixture tests validate managed behavior and the pinned Lua +fixture; opt-in live evidence is recorded separately and is not implied by these wrappers. A `CEObject` is the native object pointer and nothing else. `CEObject.TryRead` decodes it from a full userdata whose first pointer-sized field holds the pointer, and `Push` hands it back through the host. A property is `obj.Name`. A diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md b/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md index 90acf273..c960ac48 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md @@ -1,7 +1,7 @@ # CheatEngine.SDK.LivePlugin.Coexistence -An opt-in, manual fixture for the exact-host portion -of [SDK-005](../../documentations/engineering/work-items/SDK-005.md). +An opt-in, manual fixture for the exact-host portion of qualification scenarios Q09 and Q10 (see +[qualification](../../docs/qualification/README.md)). It builds two distinct plugin assemblies, `PluginA` and `PluginB`, and records the identities that Cheat Engine actually loads. It is not a unit test, it is not part of ordinary CI, and this repository contains **no executed result** for it. diff --git a/tests/CheatEngine.SDK.LiveProbe/README.md b/tests/CheatEngine.SDK.LiveProbe/README.md index 14f762a1..9348cb0a 100644 --- a/tests/CheatEngine.SDK.LiveProbe/README.md +++ b/tests/CheatEngine.SDK.LiveProbe/README.md @@ -104,5 +104,4 @@ normal cleanup route. - No live test is invoked by `dotnet test`, normal CI, Release validation or packaging. The only automated validation of this project is compilation. -Detailed result templates and evidence rules live in [ -`documentations/CheatEngine.SDK/live-probes`](../../documentations/CheatEngine.SDK/live-probes/README.md). +Result recording and evidence rules: [local qualification protocol](../../docs/qualification/local-protocol.md). diff --git a/tests/CheatEngine.SDK.NativeAotLibraryProbe/README.md b/tests/CheatEngine.SDK.NativeAotLibraryProbe/README.md index 25c9e612..963c7888 100644 --- a/tests/CheatEngine.SDK.NativeAotLibraryProbe/README.md +++ b/tests/CheatEngine.SDK.NativeAotLibraryProbe/README.md @@ -13,5 +13,5 @@ dotnet publish tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.Nativ The adjacent loader harness can inspect its export directory without loading it, then—only with an acknowledgement—map the fixed-name output placed beside its own published executable and query the two names. It locks the file while it checks and maps it, and intentionally does not call `NativeLibrary.Free`. -See [ADR-006](../../documentations/engineering/ADR-006-nativeaot-plugin-loader-profile.md) and the harness README for -the exact boundary. +See the [NativeAOT profile restrictions](../../docs/abi/nativeaot-profile.md) and the harness README for the exact +boundary. diff --git a/tests/CheatEngine.SDK.NativeAotLoaderHarness/README.md b/tests/CheatEngine.SDK.NativeAotLoaderHarness/README.md index f5368c70..889b93d0 100644 --- a/tests/CheatEngine.SDK.NativeAotLoaderHarness/README.md +++ b/tests/CheatEngine.SDK.NativeAotLoaderHarness/README.md @@ -24,5 +24,5 @@ artifacts/nativeaot-loader-profile/CheatEngine.SDK.NativeAotLoaderHarness.exe -- ``` `library.unload=not-attempted` is an intentional result. NativeAOT shared libraries do not support `FreeLibrary`/ -`dlclose` unloading, so process termination is the boundary for this observation. See -[ADR-006](../../documentations/engineering/ADR-006-nativeaot-plugin-loader-profile.md). +`dlclose` unloading, so process termination is the boundary for this observation. See the +[NativeAOT profile restrictions](../../docs/abi/nativeaot-profile.md). diff --git a/tests/CheatEngine.SDK.Tests/README.md b/tests/CheatEngine.SDK.Tests/README.md index 78d40ae0..ac1825ac 100644 --- a/tests/CheatEngine.SDK.Tests/README.md +++ b/tests/CheatEngine.SDK.Tests/README.md @@ -106,6 +106,6 @@ dotnet test --project tests/CheatEngine.SDK.Tests - The checked-in C11 Lua protection bridge is parsed as PE/COFF without loading it: it is PE32+ AMD64, exports exactly four symbols, imports only its reviewed CRT/Kernel32 contract, has no delay-load table and cannot acquire a Lua module. Its build and publish copies are SHA-256-identical to the audited source asset (`NativeBridgePeAuditTests` and - `NativeBridgePackagingAuditTests`; the detailed contract is - `native/cheatengine-sdk-lua-bridge/AUDIT.md`). + `NativeBridgePackagingAuditTests`; the bridge contract is described in the + [bridge README](../../native/cheatengine-sdk-lua-bridge/README.md)). - Consumers build against the package packed by this run, never an earlier extraction (`RestoreIsolationTests`). From 506014a8a6dbb35d77ec167a004f16215b3590fb Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:10:23 +0200 Subject: [PATCH 008/199] Neutralize documentation claims without current evidence Several READMEs stated things that no committed file, test or workflow supports (audit A00-03, AR-01, ADR-12e, AX07-15, SRC03-06). Reword them to what is true today, without linking to anything that does not exist: - exemples/api: CESDK9101 does not only stop x86. The packaged target (unchanged since 1.0.0) accepts an unset PlatformTarget, AnyCPU or x64 and rejects every other explicit value, as PlatformTargetTests proves for x86, ARM, ARM64, Itanium and an unknown value. - LiveProbe: "the only automated validation of this project is compilation" is false; no workflow builds it (it is outside the solution). The qualification work states its compile check once the project joins the solution. - Hosting and Coexistence: the architecture-review scenario ids R25/T049-T050, R26/T051-T052 and R34/T067-T068/T076 only resolved in a retired archive page. They become the audit scenarios Q09/Q10, Q19 and Q30, still "not executed"; docs/README.md records the mapping. - Abi: the source index that recorded the installed-file hashes was retired. Those hashes stay declarations (DeclaredRepo) until the support profile re-measures them, instead of reading as verified. A sweep of every tracked Markdown file for "100 %", "complete/full coverage", "fully supported", "all public Lua", "every CE version or profile", "7.5.1" and "exact CE 7.7 source" finds nothing to neutralize. --- exemples/api/README.md | 2 +- libs/CheatEngine.SDK.Abi/README.md | 4 +++- libs/CheatEngine.SDK.Hosting/README.md | 4 ++-- tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md | 8 ++++---- tests/CheatEngine.SDK.LiveProbe/README.md | 3 +-- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/exemples/api/README.md b/exemples/api/README.md index cc0f0205..1dd4b751 100644 --- a/exemples/api/README.md +++ b/exemples/api/README.md @@ -78,7 +78,7 @@ dotnet add package CheatEngine.SDK --version 1.0.0 | `AllowUnsafeBlocks` | Consumer opt-in | Set `true` for `[LuaFunction]` exports, whose generated thunks take native addresses; a `[LuaGlobal]`-only project can leave it `false` | | `EnableDynamicLoading` | `true` while empty | Copies referenced assemblies next to your plugin and writes its runtime config | | `CheatEngineSdkGenerateEntryPoint` | `true` while empty | Set `false` to write `CESDK.CESDK.CEPluginInitialize` by hand | -| `PlatformTarget` | yours | Must not be `x86`: the build stops with `CESDK9101` | +| `PlatformTarget` | yours | Leave it unset, or set `AnyCPU` or `x64`; any other explicit value (`x86`, `ARM`, `ARM64`, …) stops the build with `CESDK9101` (`PlatformTargetTests`) | | Requirement | Version | |--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| diff --git a/libs/CheatEngine.SDK.Abi/README.md b/libs/CheatEngine.SDK.Abi/README.md index 168a61bd..75e690d1 100644 --- a/libs/CheatEngine.SDK.Abi/README.md +++ b/libs/CheatEngine.SDK.Abi/README.md @@ -64,7 +64,9 @@ official [pinned and [pinned `plugin.pas`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/plugin.pas). -The source index records the installed-file hashes reviewed for the historic CE 7.7 baseline. The independently +These installed-file hashes were recorded in a source index that was retired on 2026-09-22 and is not restored; they +remain declarations (`DeclaredRepo`) until the [support profile](../../docs/qualification/support-profile.md) +re-measures them. The independently compiled fixture is deliberately more limited: it compiles a checked-in transcription of the pinned upstream C-header subset under MSVC x64, validates 104 facts, and compares its `sizeof`, `offsetof`, alignment, export, and topology facts with a versioned expectation. The Debug CI test run also passes that facts file into a compiled managed test, which measures diff --git a/libs/CheatEngine.SDK.Hosting/README.md b/libs/CheatEngine.SDK.Hosting/README.md index 5d241986..6748a36e 100644 --- a/libs/CheatEngine.SDK.Hosting/README.md +++ b/libs/CheatEngine.SDK.Hosting/README.md @@ -110,8 +110,8 @@ all CE/Lua participants are serialized. The opt-in [two-plugin live fixture](../../tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md) logs the exact plugin, Hosting-assembly and runtime `AssemblyLoadContext` identities for a controlled host run. It is an observation protocol, not a CI test or a portability promise. It must be run and recorded before a supported coexistence profile is -claimed. The related architecture-review scenarios R25/T049–T050, R26/T051–T052 and R34/T067–T068/T076 remain -specified, not executed. +claimed. The related qualification scenarios Q09 and Q10 (coexistence), Q19 (first calls from two workers) and Q30 +(target switch) remain not executed. The current supported route is the managed, framework-dependent plugin route. The standalone Native AOT probe checks library publication constraints; it does not establish that Cheat Engine can load, disable, unload or remove a Native diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md b/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md index c960ac48..f8f57bce 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/README.md @@ -42,8 +42,8 @@ DLL, record: - the timestamp, operator and complete DebugView transcript; and - the Plugin A/B identity lines, every Lua command result, and any loader/enable failure. -Leaving any field unknown means the result is an unqualified manual observation. The architecture-review scenarios -remain `Specified_Not_Executed`: R25/T049–T050, R26/T051–T052, and R34/T067–T068/T076. +Leaving any field unknown means the result is an unqualified manual observation. Qualification scenarios Q09 and Q10 +remain not executed, as do the related scenarios Q19 and Q30. ## Build and run @@ -98,8 +98,8 @@ See [Native AOT libraries](https://learn.microsoft.com/dotnet/core/deploying/nat ## Scope deliberately left to follow-up fixtures -- `T051` and `T052`: first-thread Lua acquisition, refusal and cross-plugin worker/main-thread concurrency; -- `T068` and `T076`: target switch and retained allocation/patch ownership; and +- Q19: first-thread Lua acquisition, refusal and cross-plugin worker/main-thread concurrency; +- Q30: target switch and retained allocation/patch ownership; and - a real side-by-side package test, after the package tuple and loader profile are identified. Those scenarios need their production owners and exact host facts. This fixture must not be used to advertise them as diff --git a/tests/CheatEngine.SDK.LiveProbe/README.md b/tests/CheatEngine.SDK.LiveProbe/README.md index 9348cb0a..279b66c5 100644 --- a/tests/CheatEngine.SDK.LiveProbe/README.md +++ b/tests/CheatEngine.SDK.LiveProbe/README.md @@ -101,7 +101,6 @@ normal cleanup route. reset/generation contract. - The worker-and-registry observation is opt-in only. A distinct worker Lua pointer may be a coroutine sharing the main virtual machine, heap and registry, so it is not evidence of independent heaps or safe concurrent execution. -- No live test is invoked by `dotnet test`, normal CI, Release validation or packaging. The only automated validation of - this project is compilation. +- No live test is invoked by `dotnet test`, normal CI, Release validation or packaging. Result recording and evidence rules: [local qualification protocol](../../docs/qualification/local-protocol.md). From 0cde9a692b542d4a052eff9d3817bea259c2c05e Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:32:37 +0200 Subject: [PATCH 009/199] Add documentation integrity tests The dead documentations/ links rotted because the checks that caught them were Python scripts that CI stopped running. Repository rules live in C# tests instead (.coderabbit.yaml, "no script-only gates"), and this one is blocking and offline: it runs in both build-test legs through dotnet test --solution, reads files only, never starts a process and never reaches the network (audit F14, ADR-12, A21-32, A23-F14-4). DocumentationIntegrityTests applies one rule per test to every Markdown file of the working tree: - relative links, images, reference definitions and HTML href/src resolve with exact case (Windows, where CI runs, is case-insensitive, so every segment is matched against the directory entries), never above the root or into build output; - #fragments match a heading (GitHub slug rules, duplicate suffixes) or an explicit anchor of the target page; - no absolute local path (drive, file: URI, user profile folder); - no link into the retired documentations/ tree, and only the docs index names retired pages; - blob/main and tree/main links to this repository resolve, and so do the frozen links of the README published in 1.0.0 (ADR-12); - packed READMEs use absolute https:// links only, since nuget.org cannot resolve relative ones (https://learn.microsoft.com/nuget/nuget-org/package-readme-on-nuget-org#allowed-domains-for-images-and-badges); - docs/ pages carry the "Recreated 2026-09" header, placeholders name their lot and wave, and the docs index links every top-level entry; - no "100 %", "complete/full coverage", "fully supported" or "fully compatible" claim unless the line negates it (A00-03, A20-14). MarkdownDocument is a dependency-free CommonMark subset parser (adding Markdig would touch central package versions and lock files): fenced code, code spans, HTML comments and backslash escapes are opaque, link text may span lines (a line-by-line parser missed the LiveProbe link), and every link maps back to the line of its destination. Repository- specific values live in DocumentationConventions so the Client can mirror the design. MarkdownDocumentTests exercises the parser and every rule on in-memory pages, which proves each gate fails on its regression. --- .../Documentation/DocumentationConventions.cs | 122 ++ .../DocumentationIntegrityTests.cs | 204 ++++ .../Documentation/DocumentationRules.cs | 382 ++++++ .../Documentation/LinkTarget.cs | 59 + .../Documentation/LinkTargetKind.cs | 17 + .../Documentation/MarkdownCorpus.cs | 76 ++ .../Documentation/MarkdownDocument.cs | 1032 +++++++++++++++++ .../Documentation/MarkdownDocumentTests.cs | 308 +++++ .../Documentation/MarkdownHeading.cs | 4 + .../Documentation/MarkdownLink.cs | 4 + .../Documentation/MarkdownLinkKind.cs | 17 + .../Documentation/MarkdownTextMatch.cs | 4 + .../Documentation/PathLookup.cs | 4 + .../Documentation/RepositoryPaths.cs | 184 +++ .../README.md | 23 + 15 files changed, 2440 insertions(+) create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationConventions.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationIntegrityTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationRules.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/LinkTarget.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/LinkTargetKind.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownCorpus.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownDocument.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownDocumentTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownHeading.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownLink.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownLinkKind.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownTextMatch.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/PathLookup.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Documentation/RepositoryPaths.cs diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationConventions.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationConventions.cs new file mode 100644 index 00000000..d9015780 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationConventions.cs @@ -0,0 +1,122 @@ +using System.Text.RegularExpressions; + +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// +/// Repository-specific values of the documentation integrity contract. The parser, the path resolver and the rules +/// read every value that differs between the SDK and the Client repository from here, so the design can be mirrored +/// by changing this class only. +/// +internal static partial class DocumentationConventions +{ + /// Timeout of every regular expression of the documentation tests. + public const int RegexTimeoutMilliseconds = 1000; + + /// The GitHub owner/name of this repository. + public const string RepositorySlug = "CheatEngineNet/CheatEngine.SDK"; + + /// The branch whose absolute blob/tree links must resolve on the current tree. + public const string MainBranch = "main"; + + /// The documentation tree that commit 4020a32 removed and that is never restored. + public const string RetiredTreePrefix = "documentations/"; + + /// The folder of the pages rebuilt after the 2026-09-22 audit. + public const string DocsFolder = "docs"; + + /// The index of . + public const string DocsIndex = "docs/README.md"; + + /// The line every page under carries (shared contract, section 7). + public const string RecreatedHeader = "> Recreated 2026-09 from the audit, not the historical documentations/ tree."; + + /// Start of a placeholder status line; such a line must match . + public const string PlaceholderPrefix = "Status: placeholder"; + + /// + /// Folder names skipped in addition to the build-output and tool-state folders that + /// RepositoryRoot.EnumerateSourceFiles already skips. BenchmarkDotNet writes *-report-github.md files. + /// + public static readonly string[] ExtraExcludedSegments = ["BenchmarkDotNet.Artifacts", ".xmake", "node_modules"]; + + /// + /// Folder names that only ever hold build output or tool state. A link into one of them cannot resolve on GitHub, + /// even when the folder exists on a developer's disk. + /// + public static readonly string[] UncommittedSegments = + [ + "artifacts", "bin", "obj", ".git", ".idea", ".vs", "TestResults", "BenchmarkDotNet.Artifacts", ".xmake", + "node_modules" + ]; + + /// + /// The only files allowed to name retired paths in text. docs/README.md lists them, as code spans and never as + /// links, in its retired-documentation table. + /// + public static readonly string[] RetiredReferenceExemptions = [DocsIndex]; + + /// + /// The absolute links of the README packed into CheatEngine.SDK 1.0.0 (git show v1.0.0:src/CheatEngine.SDK/README.md, + /// lines 72, 117 and 148). That README is published on nuget.org and can never change, so this list is frozen + /// here rather than read from the current file (audit ADR-12, consequence on distributed links). + /// + public static readonly string[] PublishedPackageReadmeLinks = + [ + "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/tests/CheatEngine.SDK.LivePlugin/README.md#run-it-in-cheat-engine", + "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/README.md", + "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/LICENSE" + ]; + + /// + /// An absolute link back into this repository on . Group rest holds the path, the + /// optional query and the optional fragment as written. + /// + public static readonly Regex SelfLinkPattern = new( + "(?i:https://github\\.com/" + Regex.Escape(RepositorySlug) + ")/(?blob|tree)/" + Regex.Escape(MainBranch) + + "/(?[^\\s)\\]\"'`>]*)", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, + TimeSpan.FromMilliseconds(RegexTimeoutMilliseconds)); + + /// + /// A placeholder status line: the owning lot (S-QUAL, S-CAT-A, DOCS-FINAL, ...) and its wave + /// (V1 to V4, optionally a to c), separated from the prefix by an em dash (U+2014). + /// + [GeneratedRegex("^Status: placeholder \u2014 content arrives with (S-[A-Z]+(-[A-Z]+)*|DOCS-FINAL) \\((V[1-4][a-c]?)\\)$", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, RegexTimeoutMilliseconds)] + public static partial Regex PlaceholderPattern + { + get; + } + + /// + /// A reference to a retired page, forbidden in text outside : a path under + /// or the removed bridge audit page. A bare documentations/ (the name of the + /// tree) stays allowed. + /// + [GeneratedRegex("documentations/[A-Za-z]|cheatengine-sdk-lua-bridge/AUDIT\\.md", RegexOptions.CultureInvariant, + RegexTimeoutMilliseconds)] + public static partial Regex RetiredReferencePattern + { + get; + } + + /// + /// Wording that claims complete coverage or universal support (audit A00-03, A20-14, F16 "no global percentage"). + /// Broader wording ("all public Lua", "every CE profile") stays a manual review item, because later pages are + /// expected to state it negatively. + /// + [GeneratedRegex("(?A negation that, earlier on the same line, turns a match into a denial. + [GeneratedRegex("\\b(not|never|no|without)\\b", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture, RegexTimeoutMilliseconds)] + public static partial Regex NegationPattern + { + get; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationIntegrityTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationIntegrityTests.cs new file mode 100644 index 00000000..57dab20c --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationIntegrityTests.cs @@ -0,0 +1,204 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// +/// Every Markdown file of the repository stays readable and verifiable from the repository alone (audit F14, ADR-12): +/// links and anchors resolve with GitHub's exact case, no page depends on a local folder or on the retired +/// documentations/ tree, the packed README only uses absolute links, the links of the published 1.0.0 README +/// keep resolving, and the rebuilt docs/ pages carry their header and an honest status. Offline and fast: the +/// tests read files only and never start a process or reach the network. +/// +public sealed class DocumentationIntegrityTests +{ + [Fact] + public void Every_relative_markdown_link_resolves_with_exact_casing() + { + List offenders = []; + int relativeTargets = 0; + foreach ((string path, MarkdownDocument document) in Corpus()) + { + foreach (MarkdownLink link in document.Links) + { + if (LinkTarget.Parse(link.Target).Kind == LinkTargetKind.Relative) + { + relativeTargets++; + } + } + + offenders.AddRange(DocumentationRules.BrokenRelativeLinks(path, document)); + } + + Assert.True(relativeTargets > 0, "No relative Markdown link was found: the corpus enumeration is broken."); + AssertNone(offenders, "These relative targets do not exist with this exact case on GitHub"); + } + + [Fact] + public void Every_markdown_anchor_matches_a_heading_of_its_target_page() + { + List offenders = []; + foreach ((string path, MarkdownDocument document) in Corpus()) + { + offenders.AddRange(DocumentationRules.BrokenAnchors(path, document)); + } + + AssertNone(offenders, "These fragments match no heading anchor (GitHub slug rules) or explicit anchor"); + } + + [Fact] + public void No_markdown_file_contains_an_absolute_local_path() + { + List offenders = []; + foreach ((string path, MarkdownDocument document) in Corpus()) + { + offenders.AddRange(DocumentationRules.LocalPaths(path, document)); + } + + AssertNone(offenders, "Documentation must not need anyone's local folder to be understood"); + } + + [Fact] + public void No_markdown_file_refers_to_the_retired_documentations_tree() + { + List offenders = []; + foreach ((string path, MarkdownDocument document) in Corpus()) + { + offenders.AddRange(DocumentationRules.RetiredReferences(path, document)); + } + + AssertNone(offenders, "The documentations/ tree and the bridge AUDIT.md were retired and are not restored"); + } + + [Fact] + public void Absolute_links_to_this_repository_on_main_resolve_on_the_current_tree() + { + List offenders = []; + int selfLinks = 0; + foreach ((string path, MarkdownDocument document) in Corpus()) + { + selfLinks += document.FindAll(DocumentationConventions.SelfLinkPattern).Count; + offenders.AddRange(DocumentationRules.BrokenSelfLinks(path, document)); + } + + Assert.True(selfLinks > 0, "No blob/main or tree/main link to this repository was found: the pattern is broken."); + AssertNone(offenders, "These absolute links to this repository on main do not resolve on the current tree"); + } + + [Fact] + public void Links_of_the_published_1_0_0_package_readme_still_resolve_on_main() + { + List offenders = []; + foreach (string url in DocumentationConventions.PublishedPackageReadmeLinks) + { + bool checkedLink = DocumentationRules.TryCheckSelfLink(url, out string? problem); + Assert.True(checkedLink, $"'{url}' is no longer recognised as a link to this repository on main."); + if (problem is not null) + { + offenders.Add($"{url} ({problem})"); + } + } + + AssertNone(offenders, + "The README packed into CheatEngine.SDK 1.0.0 on nuget.org links these paths on main; keep them resolving"); + } + + [Fact] + public void Packed_readmes_contain_only_absolute_links() + { + IReadOnlyList readmes = RepositoryPaths.PackedReadmes(); + Assert.True(readmes.Count > 0, "No packable project was found, so no packed README was checked."); + + List offenders = []; + foreach (string readme in readmes) + { + if (!MarkdownCorpus.Documents.TryGetValue(readme, out MarkdownDocument? document)) + { + offenders.Add(DocumentationRules.Offender(readme, 1, readme, "the packed README does not exist")); + continue; + } + + offenders.AddRange(DocumentationRules.NonAbsoluteLinksInPackedReadme(readme, document)); + } + + AssertNone(offenders, "Packed READMEs must use absolute https:// links"); + } + + [Fact] + public void Every_rebuilt_docs_page_starts_with_the_recreated_header() + { + List offenders = []; + int pages = 0; + foreach ((string path, MarkdownDocument document) in Corpus()) + { + if (!path.StartsWith(DocumentationConventions.DocsFolder + "/", StringComparison.Ordinal)) + { + continue; + } + + pages++; + offenders.AddRange(DocumentationRules.MissingRecreatedHeader(path, document)); + } + + Assert.True(pages > 0, $"No page was found under {DocumentationConventions.DocsFolder}/."); + AssertNone(offenders, "Pages rebuilt after the audit say so, instead of passing for restored history"); + } + + [Fact] + public void Placeholder_pages_name_their_owning_lot_and_wave() + { + List offenders = []; + foreach ((string path, MarkdownDocument document) in Corpus()) + { + offenders.AddRange(DocumentationRules.MalformedPlaceholders(path, document)); + } + + AssertNone(offenders, "A placeholder names the work that replaces it"); + } + + [Fact] + public void Docs_index_links_every_top_level_page_and_folder() + { + Assert.True(MarkdownCorpus.Documents.TryGetValue(DocumentationConventions.DocsIndex, out MarkdownDocument? index), + $"{DocumentationConventions.DocsIndex} does not exist."); + + List entries = []; + foreach (string entry in Directory.EnumerateFileSystemEntries( + Path.Combine(RepositoryRoot.Path, DocumentationConventions.DocsFolder))) + { + string name = Path.GetFileName(entry); + if (!string.Equals(name, "README.md", StringComparison.Ordinal) && !name.StartsWith('.')) + { + entries.Add(name); + } + } + + Assert.True(entries.Count > 0, $"{DocumentationConventions.DocsFolder}/ holds nothing besides its index."); + AssertNone(DocumentationRules.UnlinkedDocsEntries(index, entries), + "Every top-level page and folder of docs/ is reachable from its index"); + } + + [Fact] + public void No_markdown_file_claims_complete_coverage_or_universal_support() + { + List offenders = []; + foreach ((string path, MarkdownDocument document) in Corpus()) + { + offenders.AddRange(DocumentationRules.UniversalClaims(path, document)); + } + + AssertNone(offenders, "No page claims complete coverage or universal support without a measured denominator"); + } + + private static IReadOnlyDictionary Corpus() + { + IReadOnlyDictionary documents = MarkdownCorpus.Documents; + Assert.True(documents.Count > 0, "No Markdown file was found below the repository root."); + return documents; + } + + private static void AssertNone(List offenders, string rule) + { + Assert.True(offenders.Count == 0, + $"{rule} ({offenders.Count}):{Environment.NewLine}{string.Join(Environment.NewLine, offenders)}"); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationRules.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationRules.cs new file mode 100644 index 00000000..bdac5f14 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/DocumentationRules.cs @@ -0,0 +1,382 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// +/// The documentation rules. Each one is a function of a parsed page (and, for links, of the working tree) that returns +/// every offender as path:line -> target (reason). applies them to +/// every Markdown file of the repository; applies them to in-memory pages, which +/// proves that each gate fails on the regression it exists for. +/// +internal static class DocumentationRules +{ + private const string Arrow = " \u2192 "; + + private const string RetiredTreeReason = + "the retired documentations/ tree is not restored; re-point the link as docs/README.md#retired-documentation lists"; + + /// Trailing characters that GitHub does not include in a bare URL. + private static readonly char[] AutolinkTrailingPunctuation = ['.', ',', ':', ';', '!', '?', '*', '_', '~']; + + /// Relative targets (links, images, reference definitions, HTML attributes) that do not exist with exact case. + public static List BrokenRelativeLinks(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + List offenders = []; + foreach (MarkdownLink link in document.Links) + { + LinkTarget target = LinkTarget.Parse(link.Target); + if (target.Kind != LinkTargetKind.Relative) + { + continue; + } + + if (!RepositoryPaths.TryResolve(documentPath, target.Path, out string resolved, out string? problem)) + { + offenders.Add(Offender(documentPath, link.Line, link.Target, problem!)); + continue; + } + + PathLookup lookup = RepositoryPaths.Lookup(resolved); + if (!lookup.Exists) + { + offenders.Add(Offender(documentPath, link.Line, link.Target, lookup.Problem!)); + } + } + + return offenders; + } + + /// + /// Fragments that match no heading anchor or explicit anchor of their page: #frag on the same page, + /// page.md#frag, or folder/#frag (the folder's rendered README). Targets that do not resolve are left to + /// ; fragments of non-Markdown files (source line anchors) are not checked. + /// + public static List BrokenAnchors(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + List offenders = []; + foreach (MarkdownLink link in document.Links) + { + LinkTarget target = LinkTarget.Parse(link.Target); + if (string.IsNullOrEmpty(target.Fragment)) + { + continue; + } + + string page; + IReadOnlySet? anchors; + if (target.Kind == LinkTargetKind.SameDocument) + { + page = documentPath; + anchors = document.Anchors; + } + else if (target.Kind == LinkTargetKind.Relative + && RepositoryPaths.TryResolve(documentPath, target.Path, out string resolved, out _) + && RepositoryPaths.Lookup(resolved) is { Exists: true } lookup) + { + page = lookup.IsDirectory ? Join(resolved, "README.md") : resolved; + if (!page.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + anchors = MarkdownCorpus.AnchorsOf(page); + } + else + { + continue; + } + + if (anchors is null) + { + offenders.Add(Offender(documentPath, link.Line, link.Target, + $"the folder has no README.md to hold '#{target.Fragment}'")); + } + else if (!anchors.Contains(target.Fragment)) + { + offenders.Add(Offender(documentPath, link.Line, link.Target, + $"no heading or explicit anchor '#{target.Fragment}' in '{page}'")); + } + } + + return offenders; + } + + /// Absolute local paths anywhere in the page (audit F14: no reader needs the author's local folder). + public static List LocalPaths(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + List offenders = []; + foreach (MarkdownTextMatch match in document.FindLocalPaths()) + { + offenders.Add(Offender(documentPath, match.Line, match.Value, + "absolute local path; write a repository-relative path or a placeholder such as %APPDATA%")); + } + + return offenders; + } + + /// + /// Links into the retired documentations/ tree (never allowed), and, outside + /// , any text naming a retired page. + /// + public static List RetiredReferences(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + List offenders = []; + foreach (MarkdownLink link in document.Links) + { + if (link.Target.Contains(DocumentationConventions.RetiredTreePrefix, StringComparison.Ordinal)) + { + offenders.Add(Offender(documentPath, link.Line, link.Target, RetiredTreeReason)); + } + } + + if (Array.IndexOf(DocumentationConventions.RetiredReferenceExemptions, documentPath) >= 0) + { + return offenders; + } + + foreach (MarkdownTextMatch match in document.FindAll(DocumentationConventions.RetiredReferencePattern)) + { + offenders.Add(Offender(documentPath, match.Line, match.Value, + "names a retired page; only docs/README.md lists retired paths")); + } + + return offenders; + } + + /// + /// Absolute blob/main and tree/main links to this repository that do not resolve on the current tree + /// (exact case, and the anchor when the target is Markdown). Templates such as <ID> are skipped. + /// + public static List BrokenSelfLinks(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + List offenders = []; + foreach (MarkdownTextMatch match in document.FindAll(DocumentationConventions.SelfLinkPattern)) + { + if (TryCheckSelfLink(match.Value, out string? problem) && problem is not null) + { + offenders.Add(Offender(documentPath, match.Line, match.Value, problem)); + } + } + + return offenders; + } + + /// + /// Checks one absolute link to this repository on the main branch. Returns when the URL is + /// not such a link or is a template; otherwise is when the path + /// (and its anchor, for Markdown) resolves on the current tree. + /// + public static bool TryCheckSelfLink(string url, out string? problem) + { + ArgumentNullException.ThrowIfNull(url); + problem = null; + Match match = DocumentationConventions.SelfLinkPattern.Match(url); + if (!match.Success) + { + return false; + } + + string rest = match.Groups["rest"].Value.TrimEnd(AutolinkTrailingPunctuation); + if (rest.AsSpan().IndexOfAny("<{*") >= 0) + { + return false; + } + + int hash = rest.IndexOf('#', StringComparison.Ordinal); + string pathPart = hash < 0 ? rest : rest[..hash]; + string? fragment = hash < 0 ? null : Uri.UnescapeDataString(rest[(hash + 1)..]); + int query = pathPart.IndexOf('?', StringComparison.Ordinal); + if (query >= 0) + { + pathPart = pathPart[..query]; + } + + string path = Uri.UnescapeDataString(pathPart).Trim('/'); + PathLookup lookup = RepositoryPaths.Lookup(path); + if (!lookup.Exists) + { + problem = lookup.Problem; + return true; + } + + if (string.IsNullOrEmpty(fragment)) + { + return true; + } + + string page = lookup.IsDirectory ? Join(path, "README.md") : path; + if (!page.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + IReadOnlySet? anchors = MarkdownCorpus.AnchorsOf(page); + if (anchors is null || !anchors.Contains(fragment)) + { + problem = $"no heading or explicit anchor '#{fragment}' in '{page}'"; + } + + return true; + } + + /// + /// Targets of a packed README that are not absolute https:// URLs. nuget.org renders the README outside the + /// repository: relative links and images do not resolve there + /// (https://learn.microsoft.com/nuget/nuget-org/package-readme-on-nuget-org#allowed-domains-for-images-and-badges). + /// + public static List NonAbsoluteLinksInPackedReadme(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + List offenders = []; + foreach (MarkdownLink link in document.Links) + { + if (!link.Target.StartsWith("https://", StringComparison.Ordinal)) + { + offenders.Add(Offender(documentPath, link.Line, link.Target, + "a packed README is rendered on nuget.org, where only absolute https:// targets resolve")); + } + } + + return offenders; + } + + /// A page under docs/ whose first two non-blank lines do not include the recreated header. + public static List MissingRecreatedHeader(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + int seen = 0; + for (int n = 0; n < document.Lines.Count && seen < 2; n++) + { + string line = document.Lines[n].TrimEnd(); + if (line.Length == 0) + { + continue; + } + + if (string.Equals(line, DocumentationConventions.RecreatedHeader, StringComparison.Ordinal)) + { + return []; + } + + seen++; + } + + return + [ + Offender(documentPath, 1, "header", + $"one of the first two non-blank lines must be exactly '{DocumentationConventions.RecreatedHeader}'") + ]; + } + + /// Placeholder status lines, outside fenced code, that do not name their owning lot and wave. + public static List MalformedPlaceholders(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + List offenders = []; + for (int n = 0; n < document.Lines.Count; n++) + { + string line = document.Lines[n].Trim(); + if (document.IsInFencedCode(n + 1) + || !line.StartsWith(DocumentationConventions.PlaceholderPrefix, StringComparison.OrdinalIgnoreCase) + || DocumentationConventions.PlaceholderPattern.IsMatch(line)) + { + continue; + } + + offenders.Add(Offender(documentPath, n + 1, line, + "write 'Status: placeholder \u2014 content arrives with ()', for example 'S-QUAL (V1)'")); + } + + return offenders; + } + + /// + /// Claims of complete coverage or universal support outside fenced code, unless a negation comes earlier on the + /// same line (audit A00-03, A20-14). + /// + public static List UniversalClaims(string documentPath, MarkdownDocument document) + { + ArgumentNullException.ThrowIfNull(document); + List offenders = []; + for (int n = 0; n < document.Lines.Count; n++) + { + if (document.IsInFencedCode(n + 1)) + { + continue; + } + + string line = document.Lines[n]; + foreach (Match claim in DocumentationConventions.UniversalClaimPattern.Matches(line)) + { + if (!DocumentationConventions.NegationPattern.IsMatch(line[..claim.Index])) + { + offenders.Add(Offender(documentPath, n + 1, claim.Value, + "claims complete coverage or universal support; state the measured scope and profile instead")); + } + } + } + + return offenders; + } + + /// + /// Top-level entries of docs/ (other than the index) that no link of the index targets, directly or through + /// a path below them. + /// + public static List UnlinkedDocsEntries(MarkdownDocument index, IEnumerable topLevelEntries) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentNullException.ThrowIfNull(topLevelEntries); + List targets = []; + foreach (MarkdownLink link in index.Links) + { + LinkTarget target = LinkTarget.Parse(link.Target); + if (target.Kind == LinkTargetKind.Relative + && RepositoryPaths.TryResolve(DocumentationConventions.DocsIndex, target.Path, out string resolved, out _)) + { + targets.Add(resolved); + } + } + + List offenders = []; + foreach (string name in topLevelEntries) + { + string entry = DocumentationConventions.DocsFolder + "/" + name; + bool linked = false; + foreach (string target in targets) + { + if (string.Equals(target, entry, StringComparison.Ordinal) + || target.StartsWith(entry + "/", StringComparison.Ordinal)) + { + linked = true; + break; + } + } + + if (!linked) + { + offenders.Add(Offender(DocumentationConventions.DocsIndex, 1, entry, + "add a row for it to the Contents table of the docs index")); + } + } + + return offenders; + } + + /// Formats one offender as path:line -> target (reason). + public static string Offender(string documentPath, int line, string target, string reason) + { + return string.Create(CultureInfo.InvariantCulture, $"{documentPath}:{line}{Arrow}{target} ({reason})"); + } + + private static string Join(string folder, string file) + { + return folder.Length == 0 ? file : folder + "/" + file; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/LinkTarget.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/LinkTarget.cs new file mode 100644 index 00000000..fa8bdb8c --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/LinkTarget.cs @@ -0,0 +1,59 @@ +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// A link target split into its percent-decoded path and fragment; the query is dropped. +internal readonly record struct LinkTarget(LinkTargetKind Kind, string Path, string? Fragment) +{ + /// Classifies and splits a target as written in Markdown. + public static LinkTarget Parse(string target) + { + ArgumentNullException.ThrowIfNull(target); + string trimmed = target.Trim(); + if (trimmed.Length == 0) + { + return new LinkTarget(LinkTargetKind.Empty, string.Empty, null); + } + + if (HasScheme(trimmed) || trimmed.StartsWith("//", StringComparison.Ordinal)) + { + return new LinkTarget(LinkTargetKind.External, trimmed, null); + } + + int hash = trimmed.IndexOf('#', StringComparison.Ordinal); + string path = hash < 0 ? trimmed : trimmed[..hash]; + string? fragment = hash < 0 ? null : Uri.UnescapeDataString(trimmed[(hash + 1)..]); + int query = path.IndexOf('?', StringComparison.Ordinal); + if (query >= 0) + { + path = path[..query]; + } + + return path.Length == 0 + ? new LinkTarget(LinkTargetKind.SameDocument, string.Empty, fragment) + : new LinkTarget(LinkTargetKind.Relative, Uri.UnescapeDataString(path), fragment); + } + + /// A URI scheme as RFC 3986 defines it; a drive letter such as C: matches too and is reported elsewhere. + private static bool HasScheme(string target) + { + if (!char.IsAsciiLetter(target[0])) + { + return false; + } + + for (int i = 1; i < target.Length; i++) + { + char character = target[i]; + if (character == ':') + { + return true; + } + + if (!char.IsAsciiLetterOrDigit(character) && character is not ('+' or '.' or '-')) + { + return false; + } + } + + return false; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/LinkTargetKind.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/LinkTargetKind.cs new file mode 100644 index 00000000..affc6c05 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/LinkTargetKind.cs @@ -0,0 +1,17 @@ +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// What a Markdown link target points at. +internal enum LinkTargetKind +{ + /// No target, as in [text](). + Empty, + + /// A URI with a scheme (https:, mailto:, ...) or a protocol-relative //host target. + External, + + /// A fragment of the same page, as in #heading. + SameDocument, + + /// A path relative to the page, or to the repository root when it starts with /. + Relative +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownCorpus.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownCorpus.cs new file mode 100644 index 00000000..009e3ccf --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownCorpus.cs @@ -0,0 +1,76 @@ +using System.Collections.Concurrent; +using System.Text; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// +/// Every Markdown file of the working tree, parsed once. The enumeration reads the file system rather than +/// git ls-files (this project never starts a process) and skips build output and tool state, so it matches the +/// tracked files in a clean checkout. +/// +internal static class MarkdownCorpus +{ + private static readonly Lazy> LazyDocuments = new(Load); + + private static readonly ConcurrentDictionary OutsideCorpus = new(StringComparer.Ordinal); + + /// Documents keyed by repository-relative path, in ordinal order. + public static IReadOnlyDictionary Documents => LazyDocuments.Value; + + /// The anchors of a Markdown file, or when it does not exist. + public static IReadOnlySet? AnchorsOf(string repoRelativePath) + { + if (Documents.TryGetValue(repoRelativePath, out MarkdownDocument? document)) + { + return document.Anchors; + } + + MarkdownDocument? outside = OutsideCorpus.GetOrAdd(repoRelativePath, static path => + RepositoryPaths.ExistsWithExactCase(path) ? Read(path) : null); + return outside?.Anchors; + } + + /// Whether a repository-relative file is a Markdown document of the corpus. + public static bool IsDocumentation(string repoRelativePath) + { + if (!repoRelativePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + foreach (string segment in repoRelativePath.Split('/')) + { + foreach (string excluded in DocumentationConventions.ExtraExcludedSegments) + { + if (string.Equals(segment, excluded, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + } + + return true; + } + + private static SortedDictionary Load() + { + SortedDictionary documents = new(StringComparer.Ordinal); + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*.md")) + { + if (IsDocumentation(file)) + { + documents.Add(file, Read(file)); + } + } + + return documents; + } + + private static MarkdownDocument Read(string repoRelativePath) + { + string text = File.ReadAllText(Path.Combine(RepositoryRoot.Path, repoRelativePath), Encoding.UTF8); + return MarkdownDocument.Parse(text); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownDocument.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownDocument.cs new file mode 100644 index 00000000..58f31a3f --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownDocument.cs @@ -0,0 +1,1032 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; + +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// +/// A dependency-free parser for the part of CommonMark and GitHub Flavored Markdown that the documentation integrity +/// tests need: inline links and images (including link text that spans lines), link reference definitions, raw HTML +/// href/src attributes, ATX headings with their GitHub anchors, explicit HTML anchors, and the regions +/// whose content is not markup (fenced code, code spans, HTML comments, backslash escapes). +/// +/// +/// +/// The parser is conservative where the specification is ambiguous for this use: a fence opener may be indented by +/// any amount (fences inside list items), indented code blocks are not treated as code because list continuations +/// use indentation too, and an unclosed inline HTML comment hides nothing. Setext headings are not recognised: +/// the repository writes ATX headings only. +/// +/// +/// Line endings may be \r\n or \n. Structure is found on a masked copy of the text in which opaque +/// regions are replaced by a placeholder of the same length, so every position still maps to its line and the +/// link targets are read from the original text. +/// +/// +internal sealed partial class MarkdownDocument +{ + /// Private-use character that replaces opaque content in the masked copy; never whitespace nor markup. + private const char Opaque = '\uE000'; + + private readonly bool[] _fenced; + private readonly int[] _lineStarts; + + private MarkdownDocument(string text, string[] lines, int[] lineStarts, bool[] fenced, List links, + List headings, HashSet anchors) + { + Text = text; + Lines = lines; + _lineStarts = lineStarts; + _fenced = fenced; + Links = links; + Headings = headings; + Anchors = anchors; + } + + /// The text with every line ending normalized to \n and without a byte-order mark. + public string Text + { + get; + } + + /// The lines of , without line terminators; line n is at index n - 1. + public IReadOnlyList Lines + { + get; + } + + /// Every link, image, reference definition and HTML href/src target, in document order. + public IReadOnlyList Links + { + get; + } + + /// Every ATX heading outside fenced code and HTML comments, in document order. + public IReadOnlyList Headings + { + get; + } + + /// The fragments this page answers: heading anchors and explicit <a id>/<a name> anchors. + public IReadOnlySet Anchors + { + get; + } + + /// Parses Markdown text. + public static MarkdownDocument Parse(string text) + { + ArgumentNullException.ThrowIfNull(text); + + string normalized = text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + if (normalized.Length > 0 && normalized[0] == '\uFEFF') + { + normalized = normalized[1..]; + } + + string[] lines = normalized.Split('\n'); + int[] lineStarts = new int[lines.Length]; + for (int n = 1; n < lines.Length; n++) + { + lineStarts[n] = lineStarts[n - 1] + lines[n - 1].Length + 1; + } + + bool[] fenced = FindFencedLines(lines); + char[] masked = normalized.ToCharArray(); + for (int n = 0; n < lines.Length; n++) + { + if (fenced[n]) + { + MaskRange(masked, lineStarts[n], lineStarts[n] + lines[n].Length); + } + } + + MaskHtmlCommentBlocks(masked, lines, lineStarts); + + List links = []; + HashSet anchors = new(StringComparer.Ordinal); + List headings = ReadHeadings(masked, lines, lineStarts, anchors, out bool[] headingLines); + + foreach ((int start, int end) in Blocks(masked, lines, lineStarts, headingLines)) + { + MaskInlineOpaqueRegions(masked, start, end); + ReadInlineLinks(masked, normalized, start, end, lineStarts, links); + ReadHtmlAttributes(masked, normalized, start, end, lineStarts, links, anchors); + } + + ReadReferenceDefinitions(masked, normalized, lines, lineStarts, fenced, links); + List ordered = [.. links.OrderBy(static link => link.Line)]; + return new MarkdownDocument(normalized, lines, lineStarts, fenced, ordered, headings, anchors); + } + + /// Whether a 1-based line is a fence delimiter or the content of fenced code. + public bool IsInFencedCode(int line) + { + return _fenced[line - 1]; + } + + /// + /// Absolute local paths anywhere in the raw text, fenced code included: drive paths such as D:\x or + /// C:/x, file: URIs and user-profile folders. Environment placeholders such as %APPDATA%\x are + /// not local paths. Each match reports the whitespace-delimited token that contains it. + /// + public IReadOnlyList FindLocalPaths() + { + List matches = []; + int previousTokenStart = -1; + foreach (Match match in LocalPathRegex.Matches(Text)) + { + int tokenStart = match.Index; + while (tokenStart > 0 && !char.IsWhiteSpace(Text[tokenStart - 1])) + { + tokenStart--; + } + + if (tokenStart == previousTokenStart) + { + continue; + } + + previousTokenStart = tokenStart; + int tokenEnd = match.Index + match.Length; + while (tokenEnd < Text.Length && !char.IsWhiteSpace(Text[tokenEnd])) + { + tokenEnd++; + } + + matches.Add(new MarkdownTextMatch(LineOf(tokenStart), Text[tokenStart..tokenEnd])); + } + + return matches; + } + + /// Every match of over the raw text, fenced code included. + public IReadOnlyList FindAll(Regex pattern) + { + ArgumentNullException.ThrowIfNull(pattern); + List matches = []; + foreach (Match match in pattern.Matches(Text)) + { + matches.Add(new MarkdownTextMatch(LineOf(match.Index), match.Value)); + } + + return matches; + } + + /// + /// The GitHub anchor of a heading before duplicate suffixing: the rendered text (code spans keep their content, + /// links keep their text, images, HTML tags, comments and emphasis markers disappear), lower-cased, with letters, + /// marks, digits, - and _ kept, each space turned into -, and every other character dropped. + /// + public static string ToAnchorBase(string headingText) + { + ArgumentNullException.ThrowIfNull(headingText); + string plain = ToPlainText(headingText).ToLowerInvariant(); + StringBuilder anchor = new(plain.Length); + foreach (Rune rune in plain.EnumerateRunes()) + { + if (rune.Value is ' ' or '-' or '_') + { + anchor.Append(rune.Value == ' ' ? '-' : (char) rune.Value); + continue; + } + + switch (Rune.GetUnicodeCategory(rune)) + { + case UnicodeCategory.UppercaseLetter: + case UnicodeCategory.LowercaseLetter: + case UnicodeCategory.TitlecaseLetter: + case UnicodeCategory.ModifierLetter: + case UnicodeCategory.OtherLetter: + case UnicodeCategory.NonSpacingMark: + case UnicodeCategory.SpacingCombiningMark: + case UnicodeCategory.EnclosingMark: + case UnicodeCategory.DecimalDigitNumber: + case UnicodeCategory.LetterNumber: + case UnicodeCategory.OtherNumber: + anchor.Append(rune.ToString()); + break; + default: + break; + } + } + + return anchor.ToString(); + } + + private int LineOf(int offset) + { + return LineOf(_lineStarts, offset); + } + + private static int LineOf(int[] lineStarts, int offset) + { + int index = Array.BinarySearch(lineStarts, offset); + return (index >= 0 ? index : ~index - 1) + 1; + } + + /// Marks fence delimiter lines and fenced content. An unclosed fence runs to the end of the document. + private static bool[] FindFencedLines(string[] lines) + { + bool[] fenced = new bool[lines.Length]; + char fenceCharacter = '\0'; + int fenceLength = 0; + for (int n = 0; n < lines.Length; n++) + { + if (fenceLength == 0) + { + Match opener = FenceOpenerRegex.Match(lines[n]); + if (!opener.Success) + { + continue; + } + + string fence = opener.Groups["fence"].Value; + if (fence[0] == '`' && opener.Groups["info"].Value.Contains('`', StringComparison.Ordinal)) + { + continue; + } + + fenceCharacter = fence[0]; + fenceLength = fence.Length; + fenced[n] = true; + continue; + } + + fenced[n] = true; + string trimmed = lines[n].Trim(); + if (trimmed.Length >= fenceLength && IsRunOf(trimmed, fenceCharacter)) + { + fenceLength = 0; + } + } + + return fenced; + } + + /// Masks HTML comment blocks: a line that starts with <!-- hides everything up to -->. + private static void MaskHtmlCommentBlocks(char[] masked, string[] lines, int[] lineStarts) + { + for (int n = 0; n < lines.Length; n++) + { + int start = lineStarts[n]; + int indentation = 0; + while (indentation < 4 && start + indentation < masked.Length && masked[start + indentation] == ' ') + { + indentation++; + } + + if (indentation > 3 || !StartsWith(masked, start + indentation, masked.Length, "", start + indentation + 4, masked.Length); + int end = close < 0 ? masked.Length : close + 3; + MaskRange(masked, start + indentation, end); + } + } + + private static List ReadHeadings(char[] masked, string[] lines, int[] lineStarts, + HashSet anchors, out bool[] headingLines) + { + List headings = []; + Dictionary occurrences = new(StringComparer.Ordinal); + headingLines = new bool[lines.Length]; + for (int n = 0; n < lines.Length; n++) + { + string line = new(masked, lineStarts[n], lines[n].Length); + Match heading = AtxHeadingRegex.Match(line); + if (!heading.Success) + { + continue; + } + + headingLines[n] = true; + string content = StripClosingSequence(heading.Groups["text"].Value); + string anchor = Deduplicate(ToAnchorBase(content), occurrences); + headings.Add(new MarkdownHeading(heading.Groups["hashes"].Length, content, anchor, n + 1)); + if (anchor.Length > 0) + { + anchors.Add(anchor); + } + } + + return headings; + } + + /// The duplicate rule of GitHub's slugger: the second x becomes x-1, then x-2, and so on. + private static string Deduplicate(string anchor, Dictionary occurrences) + { + string result = anchor; + while (occurrences.ContainsKey(result)) + { + occurrences[anchor]++; + result = anchor + "-" + occurrences[anchor].ToString(CultureInfo.InvariantCulture); + } + + occurrences[result] = 0; + return result; + } + + private static string StripClosingSequence(string content) + { + string trimmed = content.Trim(); + int end = trimmed.Length; + while (end > 0 && trimmed[end - 1] == '#') + { + end--; + } + + if (end == 0) + { + return string.Empty; + } + + return end < trimmed.Length && trimmed[end - 1] is ' ' or '\t' ? trimmed[..end].TrimEnd() : trimmed; + } + + /// Paragraph-like blocks: runs of non-blank lines, with each heading line as a block of its own. + private static List<(int Start, int End)> Blocks(char[] masked, string[] lines, int[] lineStarts, bool[] headingLines) + { + List<(int Start, int End)> blocks = []; + int blockStart = -1; + int blockEnd = -1; + for (int n = 0; n < lines.Length; n++) + { + int start = lineStarts[n]; + int end = start + lines[n].Length; + bool blank = IsBlank(masked, start, end); + if (blank || headingLines[n]) + { + if (blockStart >= 0) + { + blocks.Add((blockStart, blockEnd)); + blockStart = -1; + } + + if (headingLines[n]) + { + blocks.Add((start, end)); + } + + continue; + } + + if (blockStart < 0) + { + blockStart = start; + } + + blockEnd = end; + } + + if (blockStart >= 0) + { + blocks.Add((blockStart, blockEnd)); + } + + return blocks; + } + + /// Masks backslash escapes, code spans and inline HTML comments of one block, left to right. + private static void MaskInlineOpaqueRegions(char[] masked, int start, int end) + { + int i = start; + while (i < end) + { + char character = masked[i]; + if (character == '\\' && i + 1 < end && IsAsciiPunctuation(masked[i + 1])) + { + MaskRange(masked, i, i + 2); + i += 2; + continue; + } + + if (character == '`') + { + int run = RunLength(masked, i, end, '`'); + int closing = FindBacktickRun(masked, i + run, end, run); + if (closing < 0) + { + i += run; + continue; + } + + MaskRange(masked, i, closing + run); + i = closing + run; + continue; + } + + if (character == '<' && StartsWith(masked, i, end, "", i + 4, end); + if (close >= 0) + { + MaskRange(masked, i, close + 3); + i = close + 3; + continue; + } + } + + i++; + } + } + + /// + /// Reads [text](destination "title") and ![alt](destination). The text may contain balanced brackets, + /// an image (badges) and line breaks; every opening bracket is tried, so an image nested in a link is found too. + /// + private static void ReadInlineLinks(char[] masked, string original, int start, int end, int[] lineStarts, + List links) + { + for (int i = start; i < end; i++) + { + if (masked[i] != '[') + { + continue; + } + + int close = FindClosingBracket(masked, i, end); + if (close < 0 || close + 1 >= end || masked[close + 1] != '(') + { + continue; + } + + if (!TryReadDestination(masked, original, close + 2, end, out string destination)) + { + continue; + } + + MarkdownLinkKind kind = i > start && masked[i - 1] == '!' ? MarkdownLinkKind.Image : MarkdownLinkKind.Inline; + links.Add(new MarkdownLink(kind, destination, LineOf(lineStarts, close))); + } + } + + private static int FindClosingBracket(char[] masked, int open, int end) + { + int depth = 0; + for (int i = open; i < end; i++) + { + if (masked[i] == '[') + { + depth++; + } + else if (masked[i] == ']') + { + depth--; + if (depth == 0) + { + return i; + } + } + } + + return -1; + } + + /// Reads a link destination, an optional title and the closing parenthesis, starting after ](. + private static bool TryReadDestination(char[] masked, string original, int position, int end, out string destination) + { + destination = string.Empty; + int start = SkipWhitespace(masked, position, end); + bool angled = start < end && masked[start] == '<'; + int after = angled ? EndOfAngleDestination(masked, start, end) : EndOfBareDestination(masked, start, end); + if (after < 0) + { + return false; + } + + string raw = angled ? original[(start + 1)..(after - 1)] : original[start..after]; + int i = SkipWhitespace(masked, after, end); + if (i > after && i < end && masked[i] is '"' or '\'' or '(') + { + i = EndOfTitle(masked, i, end); + if (i < 0) + { + return false; + } + + i = SkipWhitespace(masked, i, end); + } + + if (i >= end || masked[i] != ')') + { + return false; + } + + destination = Unescape(raw); + return true; + } + + /// The position after the > of a <destination>, or -1 when it is not closed on its line. + private static int EndOfAngleDestination(char[] masked, int open, int end) + { + int close = open + 1; + while (close < end && masked[close] is not ('>' or '<' or '\n')) + { + close++; + } + + return close < end && masked[close] == '>' ? close + 1 : -1; + } + + /// + /// The position after a bare destination, which stops at whitespace or at an unbalanced ); -1 when a + /// parenthesis of the destination stays open. + /// + private static int EndOfBareDestination(char[] masked, int start, int end) + { + int i = start; + int depth = 0; + while (i < end && !char.IsWhiteSpace(masked[i])) + { + if (masked[i] == '(') + { + depth++; + } + else if (masked[i] == ')') + { + if (depth == 0) + { + break; + } + + depth--; + } + + i++; + } + + return depth == 0 ? i : -1; + } + + /// The position after a "title", 'title' or (title), or -1 when it is not closed. + private static int EndOfTitle(char[] masked, int open, int end) + { + char closing = masked[open] == '(' ? ')' : masked[open]; + int close = open + 1; + while (close < end && masked[close] != closing) + { + close++; + } + + return close < end ? close + 1 : -1; + } + + /// Reads href/src attributes of raw HTML tags, and id/name anchors of a tags. + private static void ReadHtmlAttributes(char[] masked, string original, int start, int end, int[] lineStarts, + List links, HashSet anchors) + { + string block = new(masked, start, end - start); + foreach (Match tag in HtmlTagRegex.Matches(block)) + { + Group attributes = tag.Groups["attributes"]; + bool isAnchorTag = string.Equals(tag.Groups["name"].Value, "a", StringComparison.OrdinalIgnoreCase); + foreach (Match attribute in HtmlAttributeRegex.Matches(attributes.Value)) + { + Group value = attribute.Groups["value"]; + int valueStart = start + attributes.Index + value.Index; + string text = WebUtility.HtmlDecode(original.Substring(valueStart, value.Length)); + string name = attribute.Groups["name"].Value; + if (string.Equals(name, "href", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "src", StringComparison.OrdinalIgnoreCase)) + { + links.Add(new MarkdownLink(MarkdownLinkKind.HtmlAttribute, text, LineOf(lineStarts, valueStart))); + } + else if (isAnchorTag + && (string.Equals(name, "id", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "name", StringComparison.OrdinalIgnoreCase))) + { + anchors.Add(text); + } + } + } + } + + /// Reads [label]: destination lines; footnote definitions ([^1]:) are not link definitions. + private static void ReadReferenceDefinitions(char[] masked, string original, string[] lines, int[] lineStarts, + bool[] fenced, List links) + { + for (int n = 0; n < lines.Length; n++) + { + if (fenced[n]) + { + continue; + } + + string line = new(masked, lineStarts[n], lines[n].Length); + Match definition = ReferenceDefinitionRegex.Match(line); + if (!definition.Success) + { + continue; + } + + Group destination = definition.Groups["destination"]; + string raw = original.Substring(lineStarts[n] + destination.Index, destination.Length); + links.Add(new MarkdownLink(MarkdownLinkKind.ReferenceDefinition, Unescape(raw), n + 1)); + } + } + + /// The rendered text of a heading, as GitHub reads it to build the anchor. + private static string ToPlainText(string markdown) + { + StringBuilder plain = new(markdown.Length); + int i = 0; + while (i < markdown.Length) + { + int next = markdown[i] switch + { + '\\' => AppendEscape(markdown, i, plain), + '`' => AppendCodeSpan(markdown, i, plain), + '<' => SkipHtml(markdown, i), + '!' or '[' => AppendLinkText(markdown, i, plain), + '&' => AppendEntity(markdown, i, plain), + '_' or '*' => AppendEmphasisRun(markdown, i, plain), + _ => -1 + }; + + if (next < 0) + { + plain.Append(markdown[i]); + next = i + 1; + } + + i = next; + } + + return plain.ToString(); + } + + /// A backslash escape keeps the escaped character. Returns the next position, or -1 when it is no escape. + private static int AppendEscape(string markdown, int i, StringBuilder plain) + { + if (i + 1 >= markdown.Length || !IsAsciiPunctuation(markdown[i + 1])) + { + return -1; + } + + plain.Append(markdown[i + 1]); + return i + 2; + } + + /// A code span keeps its content; an unmatched backtick run stays literal. + private static int AppendCodeSpan(string markdown, int i, StringBuilder plain) + { + int run = RunLength(markdown, i, '`'); + int closing = FindBacktickRun(markdown, i + run, run); + if (closing < 0) + { + plain.Append('`', run); + return i + run; + } + + plain.Append(NormalizeCodeSpan(markdown[(i + run)..closing])); + return closing + run; + } + + /// HTML comments and tags have no text. Returns the position after them, or -1. + private static int SkipHtml(string markdown, int i) + { + if (markdown.AsSpan(i).StartsWith("", i + 4, StringComparison.Ordinal); + if (close >= 0) + { + return close + 3; + } + } + + Match tag = InlineHtmlTagRegex.Match(markdown, i); + return tag.Success ? i + tag.Length : -1; + } + + /// A link keeps its text and an image has none. Returns the position after it, or -1. + private static int AppendLinkText(string markdown, int i, StringBuilder plain) + { + bool image = markdown[i] == '!'; + int open = image ? i + 1 : i; + if (open >= markdown.Length || markdown[open] != '[' + || !TryReadHeadingLink(markdown, open, out int end, out string text)) + { + return -1; + } + + if (!image) + { + plain.Append(ToPlainText(text)); + } + + return end; + } + + /// An HTML entity is decoded. Returns the position after it, or -1. + private static int AppendEntity(string markdown, int i, StringBuilder plain) + { + Match entity = EntityRegex.Match(markdown, i); + if (!entity.Success) + { + return -1; + } + + plain.Append(WebUtility.HtmlDecode(entity.Value)); + return i + entity.Length; + } + + /// + /// Emphasis markers have no text: every *, and every _ run at a word boundary. CommonMark never opens + /// or closes underscore emphasis inside a word, so snake_case keeps its underscore. + /// + private static int AppendEmphasisRun(string markdown, int i, StringBuilder plain) + { + char marker = markdown[i]; + int run = RunLength(markdown, i, marker); + bool atWordStart = i == 0 || !char.IsLetterOrDigit(markdown[i - 1]); + bool atWordEnd = i + run >= markdown.Length || !char.IsLetterOrDigit(markdown[i + run]); + if (marker == '_' && !atWordStart && !atWordEnd) + { + plain.Append(marker, run); + } + + return i + run; + } + + /// Reads [text](destination) or [text][label] inside a heading. + private static bool TryReadHeadingLink(string markdown, int open, out int end, out string text) + { + end = open; + text = string.Empty; + int depth = 0; + int close = -1; + for (int i = open; i < markdown.Length; i++) + { + if (markdown[i] == '[') + { + depth++; + } + else if (markdown[i] == ']' && --depth == 0) + { + close = i; + break; + } + } + + if (close < 0 || close + 1 >= markdown.Length || markdown[close + 1] is not ('(' or '[')) + { + return false; + } + + char opener = markdown[close + 1]; + char closer = opener == '(' ? ')' : ']'; + int nesting = 0; + for (int i = close + 1; i < markdown.Length; i++) + { + if (markdown[i] == opener) + { + nesting++; + } + else if (markdown[i] == closer && --nesting == 0) + { + end = i + 1; + text = markdown[(open + 1)..close]; + return true; + } + } + + return false; + } + + private static string NormalizeCodeSpan(string content) + { + string singleLine = content.Replace('\n', ' '); + bool padded = singleLine.Length >= 2 && singleLine[0] == ' ' && singleLine[^1] == ' ' + && singleLine.AsSpan().ContainsAnyExcept(' '); + return padded ? singleLine[1..^1] : singleLine; + } + + private static string Unescape(string raw) + { + if (!raw.Contains('\\', StringComparison.Ordinal)) + { + return raw; + } + + StringBuilder unescaped = new(raw.Length); + for (int i = 0; i < raw.Length; i++) + { + if (raw[i] == '\\' && i + 1 < raw.Length && IsAsciiPunctuation(raw[i + 1])) + { + i++; + } + + unescaped.Append(raw[i]); + } + + return unescaped.ToString(); + } + + private static bool IsAsciiPunctuation(char character) + { + return character is >= '!' and <= '/' or >= ':' and <= '@' or >= '[' and <= '`' or >= '{' and <= '~'; + } + + private static bool IsRunOf(string text, char character) + { + foreach (char current in text) + { + if (current != character) + { + return false; + } + } + + return true; + } + + /// Whitespace only, or entirely hidden (a fenced line or an HTML comment block line). + private static bool IsBlank(char[] masked, int start, int end) + { + for (int i = start; i < end; i++) + { + if (!char.IsWhiteSpace(masked[i]) && masked[i] != Opaque) + { + return false; + } + } + + return true; + } + + private static int SkipWhitespace(char[] masked, int position, int end) + { + while (position < end && char.IsWhiteSpace(masked[position])) + { + position++; + } + + return position; + } + + private static int RunLength(char[] text, int start, int end, char character) + { + int i = start; + while (i < end && text[i] == character) + { + i++; + } + + return i - start; + } + + private static int RunLength(string text, int start, char character) + { + int i = start; + while (i < text.Length && text[i] == character) + { + i++; + } + + return i - start; + } + + /// The start of the next backtick run of exactly characters, or -1. + private static int FindBacktickRun(char[] text, int start, int end, int length) + { + int i = start; + while (i < end) + { + if (text[i] != '`') + { + i++; + continue; + } + + int run = RunLength(text, i, end, '`'); + if (run == length) + { + return i; + } + + i += run; + } + + return -1; + } + + private static int FindBacktickRun(string text, int start, int length) + { + int i = start; + while (i < text.Length) + { + if (text[i] != '`') + { + i++; + continue; + } + + int run = RunLength(text, i, '`'); + if (run == length) + { + return i; + } + + i += run; + } + + return -1; + } + + private static bool StartsWith(char[] text, int position, int end, string value) + { + if (position + value.Length > end) + { + return false; + } + + return text.AsSpan(position, value.Length).SequenceEqual(value); + } + + private static int IndexOf(char[] text, string value, int start, int end) + { + if (start >= end) + { + return -1; + } + + int index = text.AsSpan(start, end - start).IndexOf(value); + return index < 0 ? -1 : start + index; + } + + /// Replaces a range by , keeping line feeds so blocks and lines stay where they were. + private static void MaskRange(char[] masked, int start, int end) + { + for (int i = start; i < end; i++) + { + if (masked[i] != '\n') + { + masked[i] = Opaque; + } + } + } + + [GeneratedRegex("^[ \\t]*(?`{3,}|~{3,})(?.*)$", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, + DocumentationConventions.RegexTimeoutMilliseconds)] + private static partial Regex FenceOpenerRegex + { + get; + } + + [GeneratedRegex("^ {0,3}(?#{1,6})(?:[ \\t]+(?.*))?$", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, DocumentationConventions.RegexTimeoutMilliseconds)] + private static partial Regex AtxHeadingRegex + { + get; + } + + [GeneratedRegex("<(?[A-Za-z][A-Za-z0-9-]*)(?(?:\\s[^<>]*)?)>", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, DocumentationConventions.RegexTimeoutMilliseconds)] + private static partial Regex HtmlTagRegex + { + get; + } + + [GeneratedRegex("(?[A-Za-z_:][A-Za-z0-9_.:-]*)\\s*=\\s*(?:\"(?[^\"]*)\"|'(?[^']*)'|(?[^\\s\"'=<>`]+))", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, DocumentationConventions.RegexTimeoutMilliseconds)] + private static partial Regex HtmlAttributeRegex + { + get; + } + + [GeneratedRegex("\\G]*)?/?>", RegexOptions.CultureInvariant, + DocumentationConventions.RegexTimeoutMilliseconds)] + private static partial Regex InlineHtmlTagRegex + { + get; + } + + [GeneratedRegex("\\G&(?:#[0-9]{1,7}|#[xX][0-9A-Fa-f]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});", RegexOptions.CultureInvariant, + DocumentationConventions.RegexTimeoutMilliseconds)] + private static partial Regex EntityRegex + { + get; + } + + [GeneratedRegex("^ {0,3}\\[(?!\\^)(?

logo

+ + text + + [Unreleased]: https://github.com/CheatEngineNet/CheatEngine.SDK/compare/v1.0.0...HEAD + [local]: "Title" + [^1]: A footnote, not a link definition. + """); + + List found = []; + foreach (MarkdownLink link in document.Links) + { + found.Add($"{link.Line} {link.Kind} {link.Target}"); + } + + found.Sort(StringComparer.Ordinal); + Assert.Equal( + [ + "1 Image https://img.shields.io/badge/build-passing.svg", + "1 Inline https://github.com/CheatEngineNet/CheatEngine.SDK/actions", + "10 ReferenceDefinition docs/README.md", + "2 Image https://img.shields.io/badge/license-MIT.svg", + "2 Inline LICENSE", + "3 Inline README.md", + "3 Inline docs/a_b.md", + "3 Inline docs/with%20space.md", + "5 HtmlAttribute CONTRIBUTING.md", + "5 HtmlAttribute docs/logo.png", + "9 ReferenceDefinition https://github.com/CheatEngineNet/CheatEngine.SDK/compare/v1.0.0...HEAD" + ], + found); + Assert.Contains("custom-anchor", document.Anchors); + Assert.Contains("named-anchor", document.Anchors); + Assert.Equal(new LinkTarget(LinkTargetKind.Relative, "docs/with space.md", null), + LinkTarget.Parse("docs/with%20space.md")); + } + + [Theory] + [InlineData("Clone into D:\\CheatEngine\\x before building.", true)] + [InlineData("See C:/Users/a/project for the layout.", true)] + [InlineData("Open file:///c:/x in a browser.", true)] + [InlineData("It lives in /home/a/project.", true)] + [InlineData("It lives in /Users/a/project.", true)] + [InlineData("```text\nD:\\wt\\s-doc\n```", true)] + [InlineData("Logs go to `%APPDATA%\\TrainerLog\\plugin.log`.", false)] + [InlineData("See https://learn.microsoft.com/dotnet and mailto:someone@example.com.", false)] + [InlineData("The analyzer ships in `analyzers/dotnet/cs`; tests load `native/lua53-64.dll`.", false)] + public void Absolute_drive_and_user_profile_paths_are_detected_but_environment_placeholders_are_not(string markdown, + bool isLocalPath) + { + List offenders = DocumentationRules.LocalPaths(SyntheticPage, MarkdownDocument.Parse(markdown)); + + Assert.Equal(isLocalPath, offenders.Count > 0); + } + + [Fact] + public void Broken_exact_case_target_is_reported() + { + MarkdownDocument document = MarkdownDocument.Parse( + "[wrong case](readme.md), [right case](README.md), [missing](missing.md) and [folder](../CheatEngine.SDK.Repository.Tests/)"); + + List offenders = DocumentationRules.BrokenRelativeLinks(SyntheticPage, document); + + Assert.Equal(2, offenders.Count); + Assert.Contains(offenders, static offender => + offender.Contains("readme.md (", StringComparison.Ordinal) + && offender.Contains("differs in case from 'tests/CheatEngine.SDK.Repository.Tests/README.md'", + StringComparison.Ordinal)); + Assert.Contains(offenders, static offender => + offender.Contains("missing.md (", StringComparison.Ordinal) + && offender.Contains("does not exist", StringComparison.Ordinal)); + Assert.False(RepositoryPaths.ExistsWithExactCase("tests/CheatEngine.SDK.Repository.Tests/readme.md")); + Assert.True(RepositoryPaths.ExistsWithExactCase("tests/CheatEngine.SDK.Repository.Tests/README.md")); + } + + [Fact] + public void Targets_that_climb_above_the_root_or_point_into_build_output_are_rejected() + { + MarkdownDocument document = MarkdownDocument.Parse( + "[outside](../../outside.md) [output](../artifacts/bin/x.dll) [bin](../src/CheatEngine.SDK/bin/Debug/x.dll) " + + "[backslash](..\\README.md) [rooted](/LICENSE) [folder](../docs/) [query](../README.md?plain=1)"); + + List offenders = DocumentationRules.BrokenRelativeLinks("docs/Synthetic.md", document); + + Assert.Equal(4, offenders.Count); + Assert.Contains(offenders, static offender => offender.Contains("climbs above the repository root", StringComparison.Ordinal)); + Assert.Contains(offenders, static offender => offender.Contains("points into 'artifacts/'", StringComparison.Ordinal)); + Assert.Contains(offenders, static offender => offender.Contains("points into 'bin/'", StringComparison.Ordinal)); + Assert.Contains(offenders, static offender => offender.Contains("as a path separator", StringComparison.Ordinal)); + } + + [Fact] + public void Anchor_links_are_checked_against_headings_and_explicit_anchors() + { + MarkdownDocument document = MarkdownDocument.Parse(""" + # Synthetic + + ## Retired documentation + + + + [same page](#retired-documentation), [explicit](#explicit), [misspelled](#retired-docs), + [published anchor](../tests/CheatEngine.SDK.LivePlugin/README.md#run-it-in-cheat-engine), + [folder readme](../tests/CheatEngine.SDK.LivePlugin/#run-it-in-cheat-engine), + [missing anchor](../tests/CheatEngine.SDK.LivePlugin/README.md#no-such-heading) and + [source line](../LICENSE#L1). + """); + + List offenders = DocumentationRules.BrokenAnchors("docs/Synthetic.md", document); + + Assert.Equal(2, offenders.Count); + Assert.Contains(offenders, static offender => offender.Contains("'#retired-docs' in 'docs/Synthetic.md'", StringComparison.Ordinal)); + Assert.Contains(offenders, static offender => + offender.Contains("'#no-such-heading' in 'tests/CheatEngine.SDK.LivePlugin/README.md'", StringComparison.Ordinal)); + } + + [Fact] + public void Retired_tree_references_are_reported_outside_the_docs_index() + { + MarkdownDocument document = MarkdownDocument.Parse( + "[old](../documentations/CheatEngine.SDK/SOURCES.md), `native/cheatengine-sdk-lua-bridge/AUDIT.md`, " + + "and the bare name `documentations/`."); + MarkdownDocument index = MarkdownDocument.Parse( + "| `documentations/CheatEngine.SDK/SOURCES.md` | [support profile](qualification/support-profile.md) |"); + MarkdownDocument linkingIndex = MarkdownDocument.Parse("[old](../documentations/engineering/backlog.json)"); + + Assert.Equal(3, DocumentationRules.RetiredReferences("exemples/Synthetic.md", document).Count); + Assert.Empty(DocumentationRules.RetiredReferences(DocumentationConventions.DocsIndex, index)); + Assert.Single(DocumentationRules.RetiredReferences(DocumentationConventions.DocsIndex, linkingIndex)); + } + + [Fact] + public void Absolute_links_to_this_repository_on_main_are_checked_and_templates_are_skipped() + { + const string Blob = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/"; + + Assert.True(DocumentationRules.TryCheckSelfLink(Blob + "LICENSE", out string? problem)); + Assert.Null(problem); + Assert.True(DocumentationRules.TryCheckSelfLink(Blob + "license", out problem)); + Assert.NotNull(problem); + Assert.True(DocumentationRules.TryCheckSelfLink( + Blob + "tests/CheatEngine.SDK.LivePlugin/README.md#no-such-heading", out problem)); + Assert.NotNull(problem); + Assert.True(DocumentationRules.TryCheckSelfLink( + "https://github.com/CheatEngineNet/CheatEngine.SDK/tree/main/docs", out problem)); + Assert.Null(problem); + Assert.False(DocumentationRules.TryCheckSelfLink(Blob + "analyzers/docs/.md", out _)); + Assert.False(DocumentationRules.TryCheckSelfLink( + "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/v1.0.0/LICENSE", out _)); + Assert.Empty(DocumentationRules.BrokenSelfLinks(SyntheticPage, + MarkdownDocument.Parse("Read the [license](" + Blob + "LICENSE) or " + Blob + "LICENSE."))); + } + + [Theory] + [InlineData("The SDK is 100 % compatible with Cheat Engine.", true)] + [InlineData("Complete coverage of the public Lua surface.", true)] + [InlineData("Every profile is fully supported.", true)] + [InlineData("It is fully compatible with CE 7.7.", true)] + [InlineData("The NativeAOT plugin route is not fully supported.", false)] + [InlineData("No 100% claim is made before the catalogue denominator exists.", false)] + [InlineData("```text\n100 % inside a fenced sample\n```", false)] + public void Universal_claims_are_reported_unless_a_negation_precedes_them(string markdown, bool isClaim) + { + List offenders = DocumentationRules.UniversalClaims(SyntheticPage, MarkdownDocument.Parse(markdown)); + + Assert.Equal(isClaim, offenders.Count > 0); + } + + [Theory] + [InlineData("Status: placeholder \u2014 content arrives with S-QUAL (V1)", true)] + [InlineData("Status: placeholder \u2014 content arrives with S-CAT-A (V2)", true)] + [InlineData("Status: placeholder \u2014 content arrives with DOCS-FINAL (V4c)", true)] + [InlineData("Status: placeholder - content arrives with S-QUAL (V1)", false)] + [InlineData("Status: placeholder \u2014 content arrives later", false)] + [InlineData("Status: placeholder \u2014 content arrives with S-QUAL (V5)", false)] + [InlineData("Status: placeholder \u2014 content arrives with S-QUAL (V1).", false)] + public void Placeholder_lines_must_name_their_lot_and_wave(string line, bool isValid) + { + List offenders = DocumentationRules.MalformedPlaceholders("docs/Synthetic.md", MarkdownDocument.Parse(line)); + + Assert.Equal(isValid, offenders.Count == 0); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownHeading.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownHeading.cs new file mode 100644 index 00000000..d240fe59 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownHeading.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// An ATX heading, its text as written and its GitHub anchor (duplicates already suffixed). +internal readonly record struct MarkdownHeading(int Level, string Text, string Anchor, int Line); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownLink.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownLink.cs new file mode 100644 index 00000000..dbd97057 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownLink.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// A link target as written (backslash escapes removed, still percent-encoded) and the 1-based line of its destination. +internal readonly record struct MarkdownLink(MarkdownLinkKind Kind, string Target, int Line); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownLinkKind.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownLinkKind.cs new file mode 100644 index 00000000..c0d3db33 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownLinkKind.cs @@ -0,0 +1,17 @@ +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// How a link target is written in Markdown. +internal enum MarkdownLinkKind +{ + /// An inline link, [text](target "title"). + Inline, + + /// An inline image, ![alt](target). + Image, + + /// A link reference definition, [label]: target. + ReferenceDefinition, + + /// The href or src attribute of a raw HTML tag. + HtmlAttribute +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownTextMatch.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownTextMatch.cs new file mode 100644 index 00000000..53614abc --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/MarkdownTextMatch.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// A piece of raw text and the 1-based line where it starts. +internal readonly record struct MarkdownTextMatch(int Line, string Value); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/PathLookup.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/PathLookup.cs new file mode 100644 index 00000000..519492ea --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/PathLookup.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// The result of looking a repository-relative path up segment by segment with ordinal comparison. +internal readonly record struct PathLookup(bool Exists, bool IsDirectory, string? Problem); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Documentation/RepositoryPaths.cs b/tests/CheatEngine.SDK.Repository.Tests/Documentation/RepositoryPaths.cs new file mode 100644 index 00000000..9f71ee3b --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Documentation/RepositoryPaths.cs @@ -0,0 +1,184 @@ +using System.Collections.Concurrent; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Documentation; + +/// +/// Resolves documentation link targets against the working tree the way GitHub does: case-sensitively. Windows, where +/// CI runs these tests, is case-insensitive, so File.Exists alone would accept readme.md for +/// README.md; every segment is matched against the real directory entries instead. +/// +internal static class RepositoryPaths +{ + private static readonly ConcurrentDictionary DirectoryEntries = new(StringComparer.Ordinal); + + /// Whether a repository-relative path (/-separated) exists with exactly this case. + public static bool ExistsWithExactCase(string repoRelativePath) + { + return Lookup(repoRelativePath).Exists; + } + + /// Looks a repository-relative path up with exact case and explains a miss (absent, or differs in case). + public static PathLookup Lookup(string repoRelativePath) + { + ArgumentNullException.ThrowIfNull(repoRelativePath); + string current = RepositoryRoot.Path; + string matched = string.Empty; + foreach (string segment in repoRelativePath.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (!Directory.Exists(current)) + { + return new PathLookup(false, false, $"'{matched}' is a file, not a folder"); + } + + string? exact = null; + string? otherCase = null; + foreach (string entry in Entries(current)) + { + if (string.Equals(entry, segment, StringComparison.Ordinal)) + { + exact = entry; + break; + } + + if (string.Equals(entry, segment, StringComparison.OrdinalIgnoreCase)) + { + otherCase = entry; + } + } + + string prefix = matched.Length == 0 ? string.Empty : matched + "/"; + if (exact is null) + { + return new PathLookup(false, false, + otherCase is null + ? $"'{prefix}{segment}' does not exist" + : $"'{prefix}{segment}' differs in case from '{prefix}{otherCase}'; GitHub paths are case-sensitive"); + } + + matched = prefix + exact; + current = System.IO.Path.Combine(current, exact); + } + + return new PathLookup(true, Directory.Exists(current), null); + } + + /// + /// Resolves the path of a target written in + /// to a normalized repository-relative path, without touching the disk. Fails for a target that uses \, + /// climbs above the repository root, or points into a folder that is never committed. + /// + public static bool TryResolve(string documentPath, string targetPath, out string resolved, out string? problem) + { + ArgumentNullException.ThrowIfNull(documentPath); + ArgumentNullException.ThrowIfNull(targetPath); + resolved = string.Empty; + if (targetPath.Contains('\\', StringComparison.Ordinal)) + { + problem = "uses '\\' as a path separator; GitHub needs '/'"; + return false; + } + + List segments = []; + if (!targetPath.StartsWith('/')) + { + string[] documentSegments = documentPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < documentSegments.Length - 1; i++) + { + segments.Add(documentSegments[i]); + } + } + + foreach (string segment in targetPath.Split('/')) + { + if (segment is "" or ".") + { + continue; + } + + if (string.Equals(segment, "..", StringComparison.Ordinal)) + { + if (segments.Count == 0) + { + problem = "climbs above the repository root"; + return false; + } + + segments.RemoveAt(segments.Count - 1); + continue; + } + + segments.Add(segment); + } + + foreach (string segment in segments) + { + foreach (string uncommitted in DocumentationConventions.UncommittedSegments) + { + if (string.Equals(segment, uncommitted, StringComparison.OrdinalIgnoreCase)) + { + problem = $"points into '{segment}/', which only holds build output or tool state and is never committed"; + return false; + } + } + } + + resolved = string.Join('/', segments); + problem = null; + return true; + } + + /// + /// The README of every packable project: a *.csproj that sets IsPackable to true packs the + /// sibling README.md (Directory.Build.targets convention) unless it names another + /// PackageReadmeFile. Repository-relative paths, sorted. + /// + public static IReadOnlyList PackedReadmes() + { + List readmes = []; + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + XDocument document = XDocument.Load(System.IO.Path.Combine(RepositoryRoot.Path, project)); + bool packable = false; + string readme = "README.md"; + foreach (XElement element in document.Descendants()) + { + string value = element.Value.Trim(); + string name = element.Name.LocalName; + if (string.Equals(name, "IsPackable", StringComparison.Ordinal) + && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) + { + packable = true; + } + else if (string.Equals(name, "PackageReadmeFile", StringComparison.Ordinal) && value.Length > 0) + { + readme = value.Replace('\\', '/'); + } + } + + if (packable) + { + int slash = project.LastIndexOf('/'); + readmes.Add(slash < 0 ? readme : project[..(slash + 1)] + readme); + } + } + + readmes.Sort(StringComparer.Ordinal); + return readmes; + } + + private static string[] Entries(string directory) + { + return DirectoryEntries.GetOrAdd(directory, static path => + { + List names = []; + foreach (string entry in Directory.EnumerateFileSystemEntries(path)) + { + names.Add(System.IO.Path.GetFileName(entry)); + } + + return [.. names]; + }); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index d871fa95..c9bdc808 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -17,6 +17,7 @@ this project only reads committed files. It never builds, packs, restores or sta |-------------------|----------------------------------------------------------------------------------------------------| | `Infrastructure/` | `RepositoryRoot` finds `CheatEngine.SDK.slnx` above the test binaries and enumerates source files. | | `Solution/` | `SolutionInventoryTests` compares the projects on disk with the projects listed in the solution. | +| `Documentation/` | `DocumentationIntegrityTests` checks every Markdown file: links, anchors, paths, `docs/` pages. | Later work adds one folder per contract (for example `Documentation/`, `Workflows/`, `Qualification/`). @@ -26,6 +27,28 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow `SolutionInventoryTests` (`Every_project_on_disk_is_in_the_solution_or_explicitly_excluded`). - The solution lists no missing project and the exclusion list holds no stale entry (`Every_project_in_the_solution_exists_and_no_exclusion_is_stale`). +- Every relative link, image, reference definition and HTML `href`/`src` of every Markdown file resolves to a committed + file or folder with GitHub's exact case, never above the repository root or into build output + (`Every_relative_markdown_link_resolves_with_exact_casing`). +- Every `#fragment` matches a heading anchor (GitHub slug rules, duplicates suffixed) or an explicit anchor of its page + (`Every_markdown_anchor_matches_a_heading_of_its_target_page`). +- No Markdown file contains an absolute local path such as a drive path, a `file:` URI or a user-profile folder + (`No_markdown_file_contains_an_absolute_local_path`). +- No link points into the retired `documentations/` tree, and only `docs/README.md` names retired pages + (`No_markdown_file_refers_to_the_retired_documentations_tree`). +- Absolute `blob/main` and `tree/main` links to this repository resolve on the current tree + (`Absolute_links_to_this_repository_on_main_resolve_on_the_current_tree`), and so do the frozen links of the README + published in CheatEngine.SDK 1.0.0 (`Links_of_the_published_1_0_0_package_readme_still_resolve_on_main`). +- Every README packed by a packable project uses absolute `https://` links only, and at least one is found + (`Packed_readmes_contain_only_absolute_links`). +- Every page under `docs/` carries the "Recreated 2026-09" header + (`Every_rebuilt_docs_page_starts_with_the_recreated_header`), every placeholder names its owning work and wave + (`Placeholder_pages_name_their_owning_lot_and_wave`), and the docs index links every top-level page and folder + (`Docs_index_links_every_top_level_page_and_folder`). +- No Markdown file claims complete coverage or universal support unless the same line negates it + (`No_markdown_file_claims_complete_coverage_or_universal_support`). +- The Markdown parser and every rule are self-tested on in-memory pages, so each gate is shown to fail on the + regression it exists for (`MarkdownDocumentTests`). ## Run the tests From 2ebb0faf5c09573565698d123ac88dd39dd0cc5a Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:12:54 +0200 Subject: [PATCH 010/199] Pin the .NET SDK and the code analysis level The repository is about to commit NuGet lock files. The SDK's implicit packages (Microsoft.NET.ILLink.Tasks, the ILCompiler packages) are recorded in those locks and move with the SDK version, so a roll-forward to another feature band would break every locked restore. global.json therefore requires 10.0.401 exactly (rollForward: disable) and names the install command in sdk.errorMessage, available since the .NET 10 SDK: https://learn.microsoft.com/dotnet/core/tools/global-json#rollforward https://learn.microsoft.com/dotnet/core/tools/global-json#errormessage AnalysisLevel moves from latest-recommended to the explicit pin 10.0-recommended (private property _CheatEngineSdkPinnedAnalysisLevel). On SDK 10.0.401 'latest' resolves to 10.0, so the selected NetAnalyzers global configs are identical before and after (checked with -getItem:EditorConfigFiles on a library, a Roslyn component and a test project) and the Debug build stays at zero warnings. A newer SDK band can no longer add CA/IDE errors without a reviewed commit. CESDK9004 (Directory.Build.targets, BeforeBuild) fails any project whose evaluated AnalysisLevel differs from the pin, which covers project-level and command-line overrides. ToolchainPinTests (Repository.Tests/Toolchain) keeps the committed values honest: exact SDK version with rollForward disable and no prerelease, an errorMessage naming the pinned version and install command, a release-shaped analysis level that moves with the SDK major/minor, and no other MSBuild file setting AnalysisLevel. --- Directory.Build.props | 8 +- Directory.Build.targets | 11 ++ global.json | 5 +- .../Toolchain/ToolchainPinTests.cs | 137 ++++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index cb319278..045e6003 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,13 @@ true - latest-recommended + + <_CheatEngineSdkPinnedAnalysisLevel>10.0-recommended + $(_CheatEngineSdkPinnedAnalysisLevel) true true diff --git a/Directory.Build.targets b/Directory.Build.targets index 6d2c069b..a6591c5a 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -34,4 +34,15 @@ Code="CESDK9002" Text="Directory.Packages.props pins Microsoft.CodeAnalysis.CSharp '$(_CheatEngineSdkRoslynPin)' and Workspaces '$(_CheatEngineSdkWorkspacesPin)', expected '$(RoslynComponentFloor)'. Restore the pin, or raise it together with RoslynComponentFloor in eng/RoslynComponent.props."/> + + + + +
diff --git a/global.json b/global.json index 9773a5f5..6aea4700 100644 --- a/global.json +++ b/global.json @@ -1,8 +1,9 @@ { "sdk": { "version": "10.0.401", - "rollForward": "latestFeature", - "allowPrerelease": false + "rollForward": "disable", + "allowPrerelease": false, + "errorMessage": "CheatEngine.SDK builds only with .NET SDK 10.0.401 exactly (global.json rollForward: disable, NuGet lock files). Install it with: winget install Microsoft.DotNet.SDK.10 --version 10.0.401" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs new file mode 100644 index 00000000..51ab56e9 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Toolchain; + +/// +/// The toolchain is pinned exactly: one .NET SDK (global.json with rollForward: disable, required by the +/// committed NuGet lock files because the SDK's implicit ILLink/ILCompiler packages move with its version) and one +/// code-analysis level, so the rule set only changes in a reviewed commit. The build enforces the analysis-level pin +/// with CESDK9004; these tests keep the committed values themselves honest. +/// +public sealed partial class ToolchainPinTests +{ + private const string GlobalJson = "global.json"; + private const string DirectoryBuildProps = "Directory.Build.props"; + private const string AnalysisLevelPinProperty = "_CheatEngineSdkPinnedAnalysisLevel"; + + private static readonly string[] s_msbuildFilePatterns = ["*.csproj", "*.props", "*.targets"]; + + [Fact] + public void Global_json_requires_the_exact_sdk_with_roll_forward_disabled() + { + JsonElement sdk = ReadGlobalJsonSdk(); + + string? version = sdk.GetProperty("version").GetString(); + Assert.NotNull(version); + Assert.Matches(ExactSdkVersion(), version); + Assert.Equal("disable", sdk.GetProperty("rollForward").GetString()); + Assert.Equal(JsonValueKind.False, sdk.GetProperty("allowPrerelease").ValueKind); + } + + [Fact] + public void Global_json_error_message_names_the_pinned_sdk_version() + { + JsonElement sdk = ReadGlobalJsonSdk(); + string version = sdk.GetProperty("version").GetString()!; + + Assert.True(sdk.TryGetProperty("errorMessage", out JsonElement errorMessage), + "global.json must carry sdk.errorMessage so a missing SDK fails with install instructions (.NET 10 SDK feature)."); + string? message = errorMessage.GetString(); + Assert.False(string.IsNullOrWhiteSpace(message)); + Assert.Contains(version, message, StringComparison.Ordinal); + Assert.Contains($"--version {version}", message, StringComparison.Ordinal); + } + + [Fact] + public void Analysis_level_is_pinned_to_a_release_not_latest() + { + XDocument props = XDocument.Load(RepositoryFile(DirectoryBuildProps)); + + string pin = Assert.Single(PropertyValues(props, AnalysisLevelPinProperty)); + Assert.Matches(PinnedAnalysisLevel(), pin); + Assert.DoesNotContain("latest", pin, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("preview", pin, StringComparison.OrdinalIgnoreCase); + + // Every AnalysisLevel assignment in the root props goes through the pin, so CESDK9004 compares like with like. + Assert.All(PropertyValues(props, "AnalysisLevel"), + static value => Assert.Equal($"$({AnalysisLevelPinProperty})", value)); + } + + [Fact] + public void Analysis_level_pin_moves_with_the_pinned_sdk_major_and_minor() + { + string sdkVersion = ReadGlobalJsonSdk().GetProperty("version").GetString()!; + string pin = Assert.Single(PropertyValues(XDocument.Load(RepositoryFile(DirectoryBuildProps)), + AnalysisLevelPinProperty)); + + string sdkMajorMinor = string.Join('.', sdkVersion.Split('.')[..2]); + string pinMajorMinor = pin[..pin.IndexOf('-', StringComparison.Ordinal)]; + Assert.True(string.Equals(sdkMajorMinor, pinMajorMinor, StringComparison.Ordinal), + $"global.json pins SDK {sdkVersion} but Directory.Build.props pins AnalysisLevel {pin}: raise both together."); + } + + [Fact] + public void No_project_or_props_file_overrides_the_pinned_analysis_level() + { + List offenders = []; + foreach (string pattern in s_msbuildFilePatterns) + { + foreach (string file in RepositoryRoot.EnumerateSourceFiles(pattern)) + { + if (string.Equals(file, DirectoryBuildProps, StringComparison.Ordinal)) + { + continue; + } + + XDocument document = XDocument.Load(RepositoryFile(file)); + if (PropertyValues(document, "AnalysisLevel").Count != 0) + { + offenders.Add(file); + } + } + } + + Assert.True(offenders.Count == 0, + $"Only Directory.Build.props may set AnalysisLevel (CESDK9004 also fails the build): {string.Join(", ", offenders)}"); + } + + internal static string RepositoryFile(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + /// The values of every <PropertyGroup> child element with the given name, in document order. + internal static List PropertyValues(XDocument document, string propertyName) + { + List values = []; + foreach (XElement group in document.Descendants("PropertyGroup")) + { + foreach (XElement property in group.Elements(propertyName)) + { + values.Add(property.Value.Trim()); + } + } + + return values; + } + + private static JsonElement ReadGlobalJsonSdk() + { + JsonDocumentOptions options = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(RepositoryFile(GlobalJson)), options); + return document.RootElement.GetProperty("sdk").Clone(); + } + + [GeneratedRegex(@"^\d+\.\d+\.\d{3}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ExactSdkVersion(); + + [GeneratedRegex(@"^\d+\.\d+-recommended$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PinnedAnalysisLevel(); +} From 5541dd758714781bc699dc7f2d2ea20e63cd6087 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:19:14 +0200 Subject: [PATCH 011/199] Make the NuGet audit policy explicit The previous policy relied on TreatWarningsAsErrors and did not say what happens to low and moderate advisories. It now follows the documented "dedicated audit pipeline" pattern: https://learn.microsoft.com/nuget/concepts/auditing-packages#running-nuget-audit-in-ci - Ordinary builds: NuGetAudit true, mode all, level low. NU1903 (high) and NU1904 (critical) are appended to WarningsAsErrors, so they fail restore even in a project that turns TreatWarningsAsErrors off. NU1901/NU1902 (low/moderate) and NU1900/NU1905 (audit source trouble) stay visible warnings, so an advisory published overnight does not turn every required check red without a commit. - restore -p:AuditPipeline=true (the scheduled strict audit): every NU1900-NU1905 code is an error. CESDK9009 guards the policy in three places: - BeforeBuild in every project: NuGetAudit/NuGetAuditMode/NuGetAuditLevel weakened, NU1903/NU1904 (every audit code in AuditPipeline mode) listed in NoWarn or WarningsNotAsErrors, or missing from WarningsAsErrors. Lists are compared as items split on ';', ',' and whitespace, so NU19031 never matches and case does not matter. - Before CollectNuGetAuditSuppressions (inside restore): a NuGetAuditSuppress item must be declared in Directory.Build.props with Justification and Expires (yyyy-MM-dd) metadata; the AuditPipeline run fails once Expires is past, ordinary builds never fail on the calendar. - Before GenerateNuspec of a packable project: a stable (non-prerelease) version cannot be packed while any suppression exists, so the release path never suppresses. Directory.Solution.targets asserts, for CI solution restores, that RestoreProjectsAuditedCount + RestoreSkippedCount equals RestoreProjectCount (documented "ensure restore audited projects" check). It is imported for CheatEngine.SDK.slnx: a forced restore with CI=true logs 35 audited of 35, a no-op restore 35 up to date, and -p:NuGetAudit=false fails with CESDK9009. ToolchainPinTests gains text checks for the policy, the solution-level assertion and the suppression rules. --- CheatEngine.SDK.slnx | 1 + Directory.Build.props | 29 ++++- Directory.Build.targets | 63 ++++++++++ Directory.Solution.targets | 18 +++ .../Toolchain/ToolchainPinTests.cs | 111 ++++++++++++++++++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 Directory.Solution.targets diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index aa555b09..696e7dd5 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -7,6 +7,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 045e6003..80a399eb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -24,10 +24,35 @@ true true + + + + + true all - - $(WarningsNotAsErrors);NU1900;NU1905 + low + <_CheatEngineSdkNuGetAuditCodes>NU1900;NU1901;NU1902;NU1903;NU1904;NU1905 + <_CheatEngineSdkNuGetAuditBlockingCodes>NU1903;NU1904 + $(WarningsAsErrors);$(_CheatEngineSdkNuGetAuditBlockingCodes) + $(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1905 + $(WarningsAsErrors);$(_CheatEngineSdkNuGetAuditCodes) + + diff --git a/Directory.Build.targets b/Directory.Build.targets index a6591c5a..9f54f38b 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -45,4 +45,67 @@ Code="CESDK9004" Text="$(MSBuildProjectName) builds with AnalysisLevel '$(AnalysisLevel)', but the repository pins '$(_CheatEngineSdkPinnedAnalysisLevel)'. Remove the override, or raise the pin in Directory.Build.props together with global.json."/> + + + + + <_CheatEngineSdkAuditRequiredErrors>$(_CheatEngineSdkNuGetAuditBlockingCodes) + <_CheatEngineSdkAuditRequiredErrors Condition="'$(AuditPipeline)' == 'true'">$(_CheatEngineSdkNuGetAuditCodes) + + + <_CheatEngineSdkAuditRequiredError Include="$([MSBuild]::Unescape($(_CheatEngineSdkAuditRequiredErrors.ToUpperInvariant())))"/> + <_CheatEngineSdkAuditWeakened Include="$([MSBuild]::Unescape($([System.Text.RegularExpressions.Regex]::Replace('$(NoWarn);$(WarningsNotAsErrors)', '[\s,;]+', ';').ToUpperInvariant())))"/> + <_CheatEngineSdkAuditAsError Include="$([MSBuild]::Unescape($([System.Text.RegularExpressions.Regex]::Replace('$(WarningsAsErrors)', '[\s,;]+', ';').ToUpperInvariant())))"/> + + <_CheatEngineSdkAuditWeakenedOther Include="@(_CheatEngineSdkAuditWeakened)" Exclude="@(_CheatEngineSdkAuditRequiredError)"/> + <_CheatEngineSdkAuditWeakenedRequired Include="@(_CheatEngineSdkAuditWeakened)" Exclude="@(_CheatEngineSdkAuditWeakenedOther)"/> + + <_CheatEngineSdkAuditMissingError Include="@(_CheatEngineSdkAuditRequiredError)" Exclude="@(_CheatEngineSdkAuditAsError)"/> + + + + + + + + + + <_CheatEngineSdkAuditSuppressionsFile>$([MSBuild]::NormalizePath('$(RepoRoot)', 'Directory.Build.props')) + <_CheatEngineSdkUtcToday>$([System.DateTime]::UtcNow.ToString('yyyyMMdd')) + + + + + + + + + +
diff --git a/Directory.Solution.targets b/Directory.Solution.targets new file mode 100644 index 00000000..b4f995d2 --- /dev/null +++ b/Directory.Solution.targets @@ -0,0 +1,18 @@ + + + + + + + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs index 51ab56e9..5066ab8e 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Toolchain/ToolchainPinTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; @@ -98,6 +99,85 @@ public void No_project_or_props_file_overrides_the_pinned_analysis_level() $"Only Directory.Build.props may set AnalysisLevel (CESDK9004 also fails the build): {string.Join(", ", offenders)}"); } + [Fact] + public void Nuget_audit_blocks_high_and_critical_advisories_in_every_build() + { + XDocument props = XDocument.Load(RepositoryFile(DirectoryBuildProps)); + + Assert.Equal(["true"], PropertyValues(props, "NuGetAudit")); + Assert.Equal(["all"], PropertyValues(props, "NuGetAuditMode")); + Assert.Equal(["low"], PropertyValues(props, "NuGetAuditLevel")); + + foreach (string value in PropertyValues(props, "WarningsNotAsErrors")) + { + HashSet codes = ExpandCodes(props, value); + Assert.DoesNotContain("NU1903", codes); + Assert.DoesNotContain("NU1904", codes); + } + + // Ordinary builds and the dedicated audit run both append the high and critical codes to WarningsAsErrors, so they + // block even in a project that turns TreatWarningsAsErrors off. + List warningsAsErrors = PropertyValues(props, "WarningsAsErrors"); + Assert.Equal(2, warningsAsErrors.Count); + Assert.All(warningsAsErrors, value => + { + Assert.StartsWith("$(WarningsAsErrors);", value, StringComparison.Ordinal); + HashSet codes = ExpandCodes(props, value); + Assert.Contains("NU1903", codes); + Assert.Contains("NU1904", codes); + }); + } + + [Fact] + public void Ci_solution_restores_assert_that_nuget_audit_covered_every_project() + { + // Directory.Solution.targets is imported by the solution metaproject (verified for CheatEngine.SDK.slnx), where + // NuGet's Restore target and its RestoreProjectCount / RestoreProjectsAuditedCount / RestoreSkippedCount outputs live. + XDocument targets = XDocument.Load(RepositoryFile("Directory.Solution.targets")); + + XElement target = Assert.Single(targets.Descendants("Target"), + static t => string.Equals((string?) t.Attribute("AfterTargets"), "Restore", StringComparison.Ordinal)); + Assert.Contains("'$(CI)' == 'true'", (string?) target.Attribute("Condition") ?? "", StringComparison.Ordinal); + XElement error = Assert.Single(target.Elements("Error")); + string condition = (string?) error.Attribute("Condition") ?? ""; + Assert.Contains("$(RestoreProjectCount)", condition, StringComparison.Ordinal); + Assert.Contains("$(RestoreProjectsAuditedCount)", condition, StringComparison.Ordinal); + Assert.Contains("$(RestoreSkippedCount)", condition, StringComparison.Ordinal); + Assert.Equal("CESDK9009", (string?) error.Attribute("Code")); + } + + [Fact] + public void Nuget_audit_suppressions_live_in_the_root_props_with_a_justification_and_an_expiry() + { + List misplaced = []; + foreach (string pattern in s_msbuildFilePatterns) + { + foreach (string file in RepositoryRoot.EnumerateSourceFiles(pattern)) + { + XDocument document = XDocument.Load(RepositoryFile(file)); + foreach (XElement suppression in document.Descendants("NuGetAuditSuppress")) + { + if (!string.Equals(file, DirectoryBuildProps, StringComparison.Ordinal)) + { + misplaced.Add(file); + continue; + } + + string advisory = (string?) suppression.Attribute("Include") ?? ""; + Assert.StartsWith("https://", advisory, StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(MetadataValue(suppression, "Justification")), + $"NuGetAuditSuppress '{advisory}' has no Justification."); + Assert.True(DateOnly.TryParseExact(MetadataValue(suppression, "Expires"), "yyyy-MM-dd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out _), + $"NuGetAuditSuppress '{advisory}' has no Expires date in yyyy-MM-dd form."); + } + } + } + + Assert.True(misplaced.Count == 0, + $"Declare NuGetAuditSuppress only in Directory.Build.props (CESDK9009 also fails restore): {string.Join(", ", misplaced)}"); + } + internal static string RepositoryFile(string relativePath) { return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); @@ -118,6 +198,37 @@ internal static List PropertyValues(XDocument document, string propertyN return values; } + /// + /// Splits an MSBuild code list on ;, , and whitespace, expanding $(Name) references to + /// properties defined in the same document (last definition wins); references to anything else stay opaque. + /// + private static HashSet ExpandCodes(XDocument document, string value) + { + HashSet codes = new(StringComparer.OrdinalIgnoreCase); + foreach (string token in value.Split([';', ',', ' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + if (token.StartsWith("$(", StringComparison.Ordinal) && token.EndsWith(')')) + { + string name = token[2..^1]; + List definitions = PropertyValues(document, name); + if (definitions.Count != 0 && !definitions[^1].Contains(token, StringComparison.Ordinal)) + { + codes.UnionWith(ExpandCodes(document, definitions[^1])); + continue; + } + } + + codes.Add(token); + } + + return codes; + } + + private static string? MetadataValue(XElement item, string name) + { + return (string?) item.Attribute(name) ?? item.Element(name)?.Value; + } + private static JsonElement ReadGlobalJsonSdk() { JsonDocumentOptions options = new() From bcf96be306440ce902ca7e4e86eba0622fd65682 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:25:33 +0200 Subject: [PATCH 012/199] Track the public API of the shipping libraries Every public API change must now be explicit before the 2.0 domain work changes the surface. Microsoft.CodeAnalysis.PublicApiAnalyzers (5.6.0, already pinned in Directory.Packages.props) is referenced by the six libs/ projects through eng/Shipping.props; src/CheatEngine.SDK declares no type and is not tracked. RS0016/RS0017 keep their default warning severity, which TreatWarningsAsErrors turns into build errors. PublicAPI.Shipped.txt is the surface of the published CheatEngine.SDK 1.0.0 package, not of the tagged sources alone. It was generated in a temporary detached worktree at v1.0.0 (a6fefb93) with the same SDK 10.0.401 and Roslyn 5.9.0: dotnet format analyzers --diagnostics RS0016 wrote the hand-written surface, and the five EngineApi-generated declarations of CheatEngine.SDK.Engine.Generated.MemoryScalars were taken from the RS0016 messages, because the fixer cannot edit generated documents although the analyzer does track them. That worktree then built RS0016/RS0017-clean in Release, and its seven assemblies were dumped with System.Reflection.Metadata and diffed against the dump of lib/net10.0 of the published nupkg (lock contentHash n7nHqZ8v...gA==): 155 types and 1375 members on both sides, 0 removed, 0 added, 0 changed, and every 1.0.0 type appears in the Shipped files. PublicAPI.Unshipped.txt holds the delta to HEAD: 668 additions from the same fixer, and the nine *REMOVED* lines RS0017 reported, which are exactly the known breaks: the AddressResolutionOptions constructor, UseHostSymbolTable accessors and Deconstruct; the typed function-pointer Callback fields of AddressListPluginInit and DisassemblerContextPluginInit (now void*); and the renumbered MemoryAccessFailure members DestinationTooSmall, WriteFailed and InvalidResult. No RS0026/RS0027 fired. Files are ordinally sorted after the #nullable enable header so they merge by union and sort. CESDK9003 (Directory.Build.targets) fails a libs/ project without both files, because a missing file silently disables tracking. PublicApiFileTests (Repository.Tests/PublicApi) check that every library has both files and nothing else does, the header, ordinal order without duplicates or blank lines, no *REMOVED* marker in Shipped, every removed line repeating a Shipped line exactly, and no Unshipped line redeclaring a live Shipped one. --- Directory.Build.targets | 12 + eng/Shipping.props | 10 + .../CheatEngine.SDK.Abi/PublicAPI.Shipped.txt | 126 +++ .../PublicAPI.Unshipped.txt | 28 + .../PublicAPI.Shipped.txt | 30 + .../PublicAPI.Unshipped.txt | 4 + .../PublicAPI.Shipped.txt | 891 ++++++++++++++++++ .../PublicAPI.Unshipped.txt | 521 ++++++++++ .../PublicAPI.Shipped.txt | 56 ++ .../PublicAPI.Unshipped.txt | 1 + .../PublicAPI.Shipped.txt | 259 +++++ .../PublicAPI.Unshipped.txt | 1 + .../CheatEngine.SDK.Lua/PublicAPI.Shipped.txt | 276 ++++++ .../PublicAPI.Unshipped.txt | 128 +++ .../PublicApi/PublicApiFileTests.cs | 127 +++ .../PublicApi/PublicApiLibrary.cs | 135 +++ 16 files changed, 2605 insertions(+) create mode 100644 libs/CheatEngine.SDK.Abi/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.SDK.Abi/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.SDK.Annotations/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.SDK.Annotations/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.SDK.Engine/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.SDK.Engine/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.SDK.Hosting/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Unshipped.txt create mode 100644 libs/CheatEngine.SDK.Lua/PublicAPI.Shipped.txt create mode 100644 libs/CheatEngine.SDK.Lua/PublicAPI.Unshipped.txt create mode 100644 tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiFileTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs diff --git a/Directory.Build.targets b/Directory.Build.targets index 9f54f38b..f0c812dd 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -35,6 +35,18 @@ Text="Directory.Packages.props pins Microsoft.CodeAnalysis.CSharp '$(_CheatEngineSdkRoslynPin)' and Workspaces '$(_CheatEngineSdkWorkspacesPin)', expected '$(RoslynComponentFloor)'. Restore the pin, or raise it together with RoslynComponentFloor in eng/RoslynComponent.props."/> + + + + + + + + diff --git a/libs/CheatEngine.SDK.Abi/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Abi/PublicAPI.Shipped.txt new file mode 100644 index 00000000..af9849db --- /dev/null +++ b/libs/CheatEngine.SDK.Abi/PublicAPI.Shipped.txt @@ -0,0 +1,126 @@ +#nullable enable +CheatEngine.SDK.Abi.AbiArchitecture +CheatEngine.SDK.Abi.AbiConstants +CheatEngine.SDK.Abi.Bool32 +CheatEngine.SDK.Abi.Bool32.Bool32() -> void +CheatEngine.SDK.Abi.Bool32.Bool32(int rawValue) -> void +CheatEngine.SDK.Abi.Bool32.Equals(CheatEngine.SDK.Abi.Bool32 other) -> bool +CheatEngine.SDK.Abi.Bool32.IsTrue.get -> bool +CheatEngine.SDK.Abi.Bool32.RawValue.get -> int +CheatEngine.SDK.Abi.Bool32.ToBoolean() -> bool +CheatEngine.SDK.Abi.Bool8 +CheatEngine.SDK.Abi.Bool8.Bool8() -> void +CheatEngine.SDK.Abi.Bool8.Bool8(byte rawValue) -> void +CheatEngine.SDK.Abi.Bool8.Equals(CheatEngine.SDK.Abi.Bool8 other) -> bool +CheatEngine.SDK.Abi.Bool8.IsTrue.get -> bool +CheatEngine.SDK.Abi.Bool8.RawValue.get -> byte +CheatEngine.SDK.Abi.Bool8.ToBoolean() -> bool +CheatEngine.SDK.Abi.Managed.ManagedEntryPoint +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.CheckSynchronize -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.GetLuaState -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.LuaPushClassInstance -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.LuaRegister -> void* +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.ManagedExportedFunctions() -> void +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.ProcessMessages -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.ManagedExportedFunctions.SizeOfExportedFunctions -> int +CheatEngine.SDK.Abi.Managed.PluginInitRecord +CheatEngine.SDK.Abi.Managed.PluginInitRecord.DisablePlugin -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.PluginInitRecord.EnablePlugin -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.PluginInitRecord.GetVersion -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Managed.PluginInitRecord.Name -> byte* +CheatEngine.SDK.Abi.Managed.PluginInitRecord.PluginInitRecord() -> void +CheatEngine.SDK.Abi.Managed.PluginInitRecord.Version -> uint +CheatEngine.SDK.Abi.Native.AddressListPluginInit +CheatEngine.SDK.Abi.Native.AddressListPluginInit.AddressListPluginInit() -> void +CheatEngine.SDK.Abi.Native.AddressListPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.AddressListPluginInit.Name -> byte* +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase.Finalize = 3 -> CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase.Initialize = 0 -> CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase.Phase1 = 1 -> CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPhase.Phase2 = 2 -> CheatEngine.SDK.Abi.Native.AutoAssemblerPhase +CheatEngine.SDK.Abi.Native.AutoAssemblerPluginInit +CheatEngine.SDK.Abi.Native.AutoAssemblerPluginInit.AutoAssemblerPluginInit() -> void +CheatEngine.SDK.Abi.Native.AutoAssemblerPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.DebugEventPluginInit +CheatEngine.SDK.Abi.Native.DebugEventPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.DebugEventPluginInit.DebugEventPluginInit() -> void +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.CallbackOnPopup -> void* +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.DisassemblerContextPluginInit() -> void +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Name -> byte* +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Shortcut -> byte* +CheatEngine.SDK.Abi.Native.DisassemblerRenderLinePluginInit +CheatEngine.SDK.Abi.Native.DisassemblerRenderLinePluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.DisassemblerRenderLinePluginInit.DisassemblerRenderLinePluginInit() -> void +CheatEngine.SDK.Abi.Native.FunctionPointerChangePluginInit +CheatEngine.SDK.Abi.Native.FunctionPointerChangePluginInit.Callback -> void* +CheatEngine.SDK.Abi.Native.FunctionPointerChangePluginInit.FunctionPointerChangePluginInit() -> void +CheatEngine.SDK.Abi.Native.MainMenuPluginInit +CheatEngine.SDK.Abi.Native.MainMenuPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.MainMenuPluginInit.MainMenuPluginInit() -> void +CheatEngine.SDK.Abi.Native.MainMenuPluginInit.Name -> byte* +CheatEngine.SDK.Abi.Native.MainMenuPluginInit.Shortcut -> byte* +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit.MemoryViewPluginInit() -> void +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit.Name -> byte* +CheatEngine.SDK.Abi.Native.MemoryViewPluginInit.Shortcut -> byte* +CheatEngine.SDK.Abi.Native.NativeExportNames +CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.AddressList = 0 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.AutoAssembler = 8 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.DisassemblerContext = 6 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.DisassemblerRenderLine = 7 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.FunctionPointerChange = 4 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.MainMenu = 5 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.MemoryView = 1 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.OnDebugEvent = 2 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginType.ProcessWatcherEvent = 3 -> CheatEngine.SDK.Abi.Native.PluginType +CheatEngine.SDK.Abi.Native.PluginVersion +CheatEngine.SDK.Abi.Native.PluginVersion.PluginName -> byte* +CheatEngine.SDK.Abi.Native.PluginVersion.PluginVersion() -> void +CheatEngine.SDK.Abi.Native.PluginVersion.Version -> uint +CheatEngine.SDK.Abi.Native.ProcessWatcherPluginInit +CheatEngine.SDK.Abi.Native.ProcessWatcherPluginInit.Callback -> void* +CheatEngine.SDK.Abi.Native.ProcessWatcherPluginInit.ProcessWatcherPluginInit() -> void +const CheatEngine.SDK.Abi.AbiConstants.SdkVersion = 6 -> int +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.Failure = 0 -> int +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.FullTypeName = "CESDK.CESDK" -> string! +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.MethodName = "CEPluginInitialize" -> string! +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.Namespace = "CESDK" -> string! +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.Success = 1 -> int +const CheatEngine.SDK.Abi.Managed.ManagedEntryPoint.TypeName = "CESDK" -> string! +const CheatEngine.SDK.Abi.Native.NativeExportNames.DisablePlugin = "CEPlugin_DisablePlugin" -> string! +const CheatEngine.SDK.Abi.Native.NativeExportNames.GetVersion = "CEPlugin_GetVersion" -> string! +const CheatEngine.SDK.Abi.Native.NativeExportNames.InitializePlugin = "CEPlugin_InitializePlugin" -> string! +override CheatEngine.SDK.Abi.Bool32.Equals(object? obj) -> bool +override CheatEngine.SDK.Abi.Bool32.GetHashCode() -> int +override CheatEngine.SDK.Abi.Bool32.ToString() -> string! +override CheatEngine.SDK.Abi.Bool8.Equals(object? obj) -> bool +override CheatEngine.SDK.Abi.Bool8.GetHashCode() -> int +override CheatEngine.SDK.Abi.Bool8.ToString() -> string! +static CheatEngine.SDK.Abi.AbiArchitecture.IsSupported.get -> bool +static CheatEngine.SDK.Abi.AbiArchitecture.ThrowIfUnsupported() -> void +static CheatEngine.SDK.Abi.Bool32.False.get -> CheatEngine.SDK.Abi.Bool32 +static CheatEngine.SDK.Abi.Bool32.FromBoolean(bool value) -> CheatEngine.SDK.Abi.Bool32 +static CheatEngine.SDK.Abi.Bool32.True.get -> CheatEngine.SDK.Abi.Bool32 +static CheatEngine.SDK.Abi.Bool32.explicit operator bool(CheatEngine.SDK.Abi.Bool32 value) -> bool +static CheatEngine.SDK.Abi.Bool32.implicit operator CheatEngine.SDK.Abi.Bool32(bool value) -> CheatEngine.SDK.Abi.Bool32 +static CheatEngine.SDK.Abi.Bool32.operator !(CheatEngine.SDK.Abi.Bool32 value) -> bool +static CheatEngine.SDK.Abi.Bool32.operator !=(CheatEngine.SDK.Abi.Bool32 left, CheatEngine.SDK.Abi.Bool32 right) -> bool +static CheatEngine.SDK.Abi.Bool32.operator ==(CheatEngine.SDK.Abi.Bool32 left, CheatEngine.SDK.Abi.Bool32 right) -> bool +static CheatEngine.SDK.Abi.Bool32.operator false(CheatEngine.SDK.Abi.Bool32 value) -> bool +static CheatEngine.SDK.Abi.Bool32.operator true(CheatEngine.SDK.Abi.Bool32 value) -> bool +static CheatEngine.SDK.Abi.Bool8.False.get -> CheatEngine.SDK.Abi.Bool8 +static CheatEngine.SDK.Abi.Bool8.FromBoolean(bool value) -> CheatEngine.SDK.Abi.Bool8 +static CheatEngine.SDK.Abi.Bool8.True.get -> CheatEngine.SDK.Abi.Bool8 +static CheatEngine.SDK.Abi.Bool8.explicit operator bool(CheatEngine.SDK.Abi.Bool8 value) -> bool +static CheatEngine.SDK.Abi.Bool8.implicit operator CheatEngine.SDK.Abi.Bool8(bool value) -> CheatEngine.SDK.Abi.Bool8 +static CheatEngine.SDK.Abi.Bool8.operator !(CheatEngine.SDK.Abi.Bool8 value) -> bool +static CheatEngine.SDK.Abi.Bool8.operator !=(CheatEngine.SDK.Abi.Bool8 left, CheatEngine.SDK.Abi.Bool8 right) -> bool +static CheatEngine.SDK.Abi.Bool8.operator ==(CheatEngine.SDK.Abi.Bool8 left, CheatEngine.SDK.Abi.Bool8 right) -> bool +static CheatEngine.SDK.Abi.Bool8.operator false(CheatEngine.SDK.Abi.Bool8 value) -> bool +static CheatEngine.SDK.Abi.Bool8.operator true(CheatEngine.SDK.Abi.Bool8 value) -> bool diff --git a/libs/CheatEngine.SDK.Abi/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Abi/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..13e0fce2 --- /dev/null +++ b/libs/CheatEngine.SDK.Abi/PublicAPI.Unshipped.txt @@ -0,0 +1,28 @@ +#nullable enable +*REMOVED*CheatEngine.SDK.Abi.Native.AddressListPluginInit.Callback -> delegate* unmanaged[Stdcall] +*REMOVED*CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Callback -> delegate* unmanaged[Stdcall] +CheatEngine.SDK.Abi.Native.AddressListPluginInit.Callback -> void* +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.BoundedDebugEventObservationBuffer(int capacity, CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy overflowPolicy) -> void +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.Capacity.get -> int +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.Count.get -> int +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.DroppedObservationCount.get -> long +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.OverflowPolicy.get -> CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.TryPublish(in CheatEngine.SDK.Abi.Native.DebugEventObservation observation) -> bool +CheatEngine.SDK.Abi.Native.BoundedDebugEventObservationBuffer.TryRead(out CheatEngine.SDK.Abi.Native.DebugEventObservation observation) -> bool +CheatEngine.SDK.Abi.Native.DebugEventDecision +CheatEngine.SDK.Abi.Native.DebugEventDecision.ContinueWithCheatEngine = 0 -> CheatEngine.SDK.Abi.Native.DebugEventDecision +CheatEngine.SDK.Abi.Native.DebugEventDecision.PluginOwnsContinuation = 1 -> CheatEngine.SDK.Abi.Native.DebugEventDecision +CheatEngine.SDK.Abi.Native.DebugEventDecisionHandler +CheatEngine.SDK.Abi.Native.DebugEventObservation +CheatEngine.SDK.Abi.Native.DebugEventObservation.DebugEventObservation() -> void +CheatEngine.SDK.Abi.Native.DebugEventObservation.DebugEventObservation(long sequenceNumber, uint eventCode, uint processId, uint threadId) -> void +CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy +CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy.DropNewest = 0 -> CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy +CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy.DropOldest = 1 -> CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy +CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Callback -> void* +readonly CheatEngine.SDK.Abi.Native.DebugEventObservation.EventCode -> uint +readonly CheatEngine.SDK.Abi.Native.DebugEventObservation.ProcessId -> uint +readonly CheatEngine.SDK.Abi.Native.DebugEventObservation.SequenceNumber -> long +readonly CheatEngine.SDK.Abi.Native.DebugEventObservation.ThreadId -> uint +virtual CheatEngine.SDK.Abi.Native.DebugEventDecisionHandler.Invoke(in CheatEngine.SDK.Abi.Native.DebugEventObservation observation) -> CheatEngine.SDK.Abi.Native.DebugEventDecision diff --git a/libs/CheatEngine.SDK.Annotations/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Annotations/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7e10bfee --- /dev/null +++ b/libs/CheatEngine.SDK.Annotations/PublicAPI.Shipped.txt @@ -0,0 +1,30 @@ +#nullable enable +CheatEngine.SDK.Annotations.Lifetime.CEOwnedAttribute +CheatEngine.SDK.Annotations.Lifetime.CEOwnedAttribute.CEOwnedAttribute() -> void +CheatEngine.SDK.Annotations.Lifetime.RequiresPluginEnabledAttribute +CheatEngine.SDK.Annotations.Lifetime.RequiresPluginEnabledAttribute.RequiresPluginEnabledAttribute() -> void +CheatEngine.SDK.Annotations.Lua.LuaClassAttribute +CheatEngine.SDK.Annotations.Lua.LuaClassAttribute.LuaClassAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaClassAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute +CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute.LuaFunctionAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute +CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute.LuaGlobalAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute +CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute.LuaMethodAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaMethodAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute +CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute.LuaPropertyAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Lua.LuaStackEffectAttribute +CheatEngine.SDK.Annotations.Lua.LuaStackEffectAttribute.Delta.get -> int +CheatEngine.SDK.Annotations.Lua.LuaStackEffectAttribute.LuaStackEffectAttribute(int delta) -> void +CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute +CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute.CheatEnginePluginAttribute(string! name) -> void +CheatEngine.SDK.Annotations.Plugin.CheatEnginePluginAttribute.Name.get -> string! +CheatEngine.SDK.Annotations.Threading.MainThreadOnlyAttribute +CheatEngine.SDK.Annotations.Threading.MainThreadOnlyAttribute.MainThreadOnlyAttribute() -> void +CheatEngine.SDK.Annotations.Threading.RunsOnMainThreadAttribute +CheatEngine.SDK.Annotations.Threading.RunsOnMainThreadAttribute.RunsOnMainThreadAttribute() -> void diff --git a/libs/CheatEngine.SDK.Annotations/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Annotations/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..63a44e02 --- /dev/null +++ b/libs/CheatEngine.SDK.Annotations/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +#nullable enable +CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute +CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute.LuaMarshallerAttribute(System.Type! marshallerType) -> void +CheatEngine.SDK.Annotations.Lua.LuaMarshallerAttribute.MarshallerType.get -> System.Type! diff --git a/libs/CheatEngine.SDK.Engine/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Engine/PublicAPI.Shipped.txt new file mode 100644 index 00000000..d013e807 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/PublicAPI.Shipped.txt @@ -0,0 +1,891 @@ +#nullable enable +CheatEngine.SDK.Engine.AddressList.AddressList +CheatEngine.SDK.Engine.AddressList.AddressList.AddressList() -> void +CheatEngine.SDK.Engine.AddressList.AddressList.AddressList(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.AddressList.AddressList.Equals(CheatEngine.SDK.Engine.AddressList.AddressList other) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.AddressList.AddressList.IsNull.get -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryCreateMemoryRecord(out CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryGetCount(out int count) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryGetMemoryRecord(int zeroBasedIndex, out CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryGetMemoryRecordById(CheatEngine.SDK.Engine.AddressList.MemoryRecordId id, out CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TryGetSelectedRecord(out CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressList.TrySetSelectedRecord(CheatEngine.SDK.Engine.AddressList.MemoryRecord record) -> bool +CheatEngine.SDK.Engine.AddressList.AddressListAccess +CheatEngine.SDK.Engine.AddressList.MemoryRecord +CheatEngine.SDK.Engine.AddressList.MemoryRecord.Equals(CheatEngine.SDK.Engine.AddressList.MemoryRecord other) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.AddressList.MemoryRecord.IsNull.get -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.MemoryRecord() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecord.MemoryRecord(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetAddressExpression(out string! addressExpression) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetChild(int zeroBasedIndex, out CheatEngine.SDK.Engine.AddressList.MemoryRecord child) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetCurrentAddress(out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetDescription(out string! description) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetId(out CheatEngine.SDK.Engine.AddressList.MemoryRecordId id) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetIndex(out int zeroBasedIndex) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetParent(out CheatEngine.SDK.Engine.AddressList.MemoryRecord parent) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetValue(out string! value) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryGetVariableType(out CheatEngine.SDK.Engine.Enums.VariableType variableType) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TrySetAddressExpression(string! addressExpression) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TrySetDescription(string! description) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TrySetValue(string! value) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecord.TrySetVariableType(CheatEngine.SDK.Engine.Enums.VariableType variableType) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecordId +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.CompareTo(CheatEngine.SDK.Engine.AddressList.MemoryRecordId other) -> int +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.CompareTo(object? obj) -> int +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Equals(CheatEngine.SDK.Engine.AddressList.MemoryRecordId other) -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.MemoryRecordId() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.MemoryRecordId(int value) -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Value.get -> int +CheatEngine.SDK.Engine.Allocation.AllocatedRegion +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Dispose() -> void +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.IsDisposed.get -> bool +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Release() -> void +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.Size.get -> CheatEngine.SDK.Engine.Allocation.TargetAllocationSize +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOperations +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOperations.TryAllocate(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request, out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOperations.TryDeallocate(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size) -> bool +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.Equals(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest other) -> bool +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.PreferredBaseAddress.get -> CheatEngine.SDK.Engine.Values.Address? +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.Protection.get -> CheatEngine.SDK.Engine.Enums.MemoryProtection? +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.Size.get -> CheatEngine.SDK.Engine.Allocation.TargetAllocationSize +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.TargetAllocationRequest() -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.TargetAllocationRequest(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size, CheatEngine.SDK.Engine.Values.Address? preferredBaseAddress = null, CheatEngine.SDK.Engine.Enums.MemoryProtection? protection = null) -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.Equals(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize other) -> bool +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.TargetAllocationSize() -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.TargetAllocationSize(long value) -> void +CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.Value.get -> long +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator.Allocate(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request) -> CheatEngine.SDK.Engine.Allocation.AllocatedRegion! +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator.TargetMemoryAllocator(CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOperations! operations) -> void +CheatEngine.SDK.Engine.Enums.BreakpointMethod +CheatEngine.SDK.Engine.Enums.BreakpointMethod.DebugRegister = 1 -> CheatEngine.SDK.Engine.Enums.BreakpointMethod +CheatEngine.SDK.Engine.Enums.BreakpointMethod.Exception = 2 -> CheatEngine.SDK.Engine.Enums.BreakpointMethod +CheatEngine.SDK.Engine.Enums.BreakpointMethod.Int3 = 0 -> CheatEngine.SDK.Engine.Enums.BreakpointMethod +CheatEngine.SDK.Engine.Enums.BreakpointTrigger +CheatEngine.SDK.Engine.Enums.BreakpointTrigger.Access = 1 -> CheatEngine.SDK.Engine.Enums.BreakpointTrigger +CheatEngine.SDK.Engine.Enums.BreakpointTrigger.Execute = 0 -> CheatEngine.SDK.Engine.Enums.BreakpointTrigger +CheatEngine.SDK.Engine.Enums.BreakpointTrigger.Write = 2 -> CheatEngine.SDK.Engine.Enums.BreakpointTrigger +CheatEngine.SDK.Engine.Enums.CEEnumNames +CheatEngine.SDK.Engine.Enums.ContinueMethod +CheatEngine.SDK.Engine.Enums.ContinueMethod.Run = 0 -> CheatEngine.SDK.Engine.Enums.ContinueMethod +CheatEngine.SDK.Engine.Enums.ContinueMethod.StepInto = 1 -> CheatEngine.SDK.Engine.Enums.ContinueMethod +CheatEngine.SDK.Engine.Enums.ContinueMethod.StepOver = 2 -> CheatEngine.SDK.Engine.Enums.ContinueMethod +CheatEngine.SDK.Engine.Enums.DuplicateHandling +CheatEngine.SDK.Engine.Enums.DuplicateHandling.Accept = 1 -> CheatEngine.SDK.Engine.Enums.DuplicateHandling +CheatEngine.SDK.Engine.Enums.DuplicateHandling.Error = 2 -> CheatEngine.SDK.Engine.Enums.DuplicateHandling +CheatEngine.SDK.Engine.Enums.DuplicateHandling.Ignore = 0 -> CheatEngine.SDK.Engine.Enums.DuplicateHandling +CheatEngine.SDK.Engine.Enums.EnumMarshaller +CheatEngine.SDK.Engine.Enums.EnumMarshaller.EnumMarshaller() -> void +CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.FastScanMethod.Aligned = 1 -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.FastScanMethod.LastDigits = 2 -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.FastScanMethod.NotAligned = 0 -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.Execute = 16 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ExecuteRead = 32 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ExecuteReadWrite = 64 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ExecuteWriteCopy = 128 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.None = 0 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ReadOnly = 2 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.ReadWrite = 4 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.MemoryProtection.WriteCopy = 8 -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Enums.RoundingType.ExtremeRounded = 1 -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Enums.RoundingType.Rounded = 0 -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Enums.RoundingType.Truncated = 2 -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.BiggerThan = 3 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.Changed = 9 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.DecreasedValue = 7 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.DecreasedValueBy = 8 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.ExactValue = 1 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.IncreasedValue = 5 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.IncreasedValueBy = 6 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.SmallerThan = 4 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.Unchanged = 10 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.UnknownValue = 0 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.ScanOption.ValueBetween = 2 -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.All = 10 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.AutoAssembler = 11 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Binary = 9 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Byte = 0 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.ByteArray = 8 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Custom = 13 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Double = 5 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Dword = 2 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Grouped = 14 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Pointer = 12 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Qword = 3 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Single = 4 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.String = 6 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.WideString = 7 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Enums.VariableType.Word = 1 -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Errors.EngineBindingException +CheatEngine.SDK.Engine.Errors.EngineBindingException.Binding.get -> string! +CheatEngine.SDK.Engine.Errors.EngineBindingException.EngineBindingException(string! binding) -> void +CheatEngine.SDK.Engine.Errors.EngineBindingException.EngineBindingException(string! binding, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineBindingException.EngineBindingException(string! binding, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.Capability.get -> string! +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.EngineCapabilityUnavailableException(string! capability) -> void +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.EngineCapabilityUnavailableException(string! capability, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.EngineCapabilityUnavailableException(string! capability, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineException +CheatEngine.SDK.Engine.Errors.EngineException.EngineException(string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineException.EngineException(string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.BindingFailure = 4 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.CapabilityUnavailable = 2 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.ExpectedOperationFailure = 0 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.GlobalUnavailable = 1 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.MarshallingFailure = 5 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.ProtectedLuaFailure = 3 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.EngineGlobalUnavailableException(string! operation) -> void +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.EngineGlobalUnavailableException(string! operation, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.EngineGlobalUnavailableException(string! operation, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.Operation.get -> string! +CheatEngine.SDK.Engine.Errors.EngineLuaException +CheatEngine.SDK.Engine.Errors.EngineLuaException.EngineLuaException(string! operation, CheatEngine.SDK.Lua.Calls.LuaStatus status) -> void +CheatEngine.SDK.Engine.Errors.EngineLuaException.EngineLuaException(string! operation, CheatEngine.SDK.Lua.Calls.LuaStatus status, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineLuaException.EngineLuaException(string! operation, CheatEngine.SDK.Lua.Calls.LuaStatus status, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineLuaException.Operation.get -> string! +CheatEngine.SDK.Engine.Errors.EngineLuaException.Status.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection +CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection.Argument = 0 -> CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection +CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection.Result = 1 -> CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection +CheatEngine.SDK.Engine.Errors.EngineMarshallingException +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Actual.get -> string! +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Direction.get -> CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.EngineMarshallingException(string! operation, CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection direction, string! expected, string! actual) -> void +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.EngineMarshallingException(string! operation, CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection direction, string! expected, string! actual, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.EngineMarshallingException(string! operation, CheatEngine.SDK.Engine.Errors.EngineMarshallingDirection direction, string! expected, string! actual, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Expected.get -> string! +CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Operation.get -> string! +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.EngineOperationFailedException(string! operation) -> void +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.EngineOperationFailedException(string! operation, string! message) -> void +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.EngineOperationFailedException(string! operation, string! message, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.Operation.get -> string! +CheatEngine.SDK.Engine.Generated.MemoryScalars +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.AddressResolutionOptions() -> void +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.AddressResolutionOptions(bool UseHostSymbolTable = false, bool Shallow = false) -> void +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Deconstruct(out bool UseHostSymbolTable, out bool Shallow) -> void +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Equals(CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions other) -> bool +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Shallow.get -> bool +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Shallow.init -> void +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.UseHostSymbolTable.get -> bool +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.UseHostSymbolTable.init -> void +CheatEngine.SDK.Engine.Inspection.EngineInspection +CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.DestinationTooSmall = 2 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.GlobalUnavailable = 3 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.InvalidResult = 5 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.LuaFailure = 4 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.NotFound = 1 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.InspectionStatus.Success = 0 -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.AllocationBase.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.AllocationBase.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.AllocationProtection.get -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.AllocationProtection.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.BaseAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.BaseAddress.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Deconstruct(out CheatEngine.SDK.Engine.Values.Address BaseAddress, out CheatEngine.SDK.Engine.Values.Address AllocationBase, out CheatEngine.SDK.Engine.Enums.MemoryProtection AllocationProtection, out CheatEngine.SDK.Engine.Inspection.MemorySize Size, out CheatEngine.SDK.Engine.Inspection.MemoryRegionState State, out CheatEngine.SDK.Engine.Enums.MemoryProtection Protection, out CheatEngine.SDK.Engine.Inspection.MemoryRegionType Type, out string? Extra) -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Equals(CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo other) -> bool +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Extra.get -> string? +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Extra.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.MemoryRegionInfo() -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.MemoryRegionInfo(CheatEngine.SDK.Engine.Values.Address BaseAddress, CheatEngine.SDK.Engine.Values.Address AllocationBase, CheatEngine.SDK.Engine.Enums.MemoryProtection AllocationProtection, CheatEngine.SDK.Engine.Inspection.MemorySize Size, CheatEngine.SDK.Engine.Inspection.MemoryRegionState State, CheatEngine.SDK.Engine.Enums.MemoryProtection Protection, CheatEngine.SDK.Engine.Inspection.MemoryRegionType Type, string? Extra) -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Protection.get -> CheatEngine.SDK.Engine.Enums.MemoryProtection +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Protection.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Size.get -> CheatEngine.SDK.Engine.Inspection.MemorySize +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Size.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.State.get -> CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.State.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Type.get -> CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Type.init -> void +CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionState.Committed = 4096 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionState.Free = 65536 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionState.Reserved = 8192 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionState +CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemoryRegionType.Image = 16777216 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemoryRegionType.Mapped = 262144 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemoryRegionType.Private = 131072 -> CheatEngine.SDK.Engine.Inspection.MemoryRegionType +CheatEngine.SDK.Engine.Inspection.MemorySize +CheatEngine.SDK.Engine.Inspection.MemorySize.CompareTo(CheatEngine.SDK.Engine.Inspection.MemorySize other) -> int +CheatEngine.SDK.Engine.Inspection.MemorySize.Equals(CheatEngine.SDK.Engine.Inspection.MemorySize other) -> bool +CheatEngine.SDK.Engine.Inspection.MemorySize.MemorySize() -> void +CheatEngine.SDK.Engine.Inspection.MemorySize.MemorySize(ulong value) -> void +CheatEngine.SDK.Engine.Inspection.MemorySize.Value.get -> ulong +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.Equals(CheatEngine.SDK.Engine.Inspection.ModuleFileOffset other) -> bool +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.ModuleFileOffset() -> void +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.ModuleFileOffset(ulong value) -> void +CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.Value.get -> ulong +CheatEngine.SDK.Engine.Inspection.ModuleInfo +CheatEngine.SDK.Engine.Inspection.ModuleInfo.BaseAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.ModuleInfo.BaseAddress.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Deconstruct(out string! Name, out CheatEngine.SDK.Engine.Values.Address BaseAddress, out CheatEngine.SDK.Engine.Inspection.MemorySize? ImageSize, out bool Is64Bit, out string! PathToFile) -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Equals(CheatEngine.SDK.Engine.Inspection.ModuleInfo other) -> bool +CheatEngine.SDK.Engine.Inspection.ModuleInfo.ImageSize.get -> CheatEngine.SDK.Engine.Inspection.MemorySize? +CheatEngine.SDK.Engine.Inspection.ModuleInfo.ImageSize.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Is64Bit.get -> bool +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Is64Bit.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.ModuleInfo() -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.ModuleInfo(string! Name, CheatEngine.SDK.Engine.Values.Address BaseAddress, CheatEngine.SDK.Engine.Inspection.MemorySize? ImageSize, bool Is64Bit, string! PathToFile) -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Name.get -> string! +CheatEngine.SDK.Engine.Inspection.ModuleInfo.Name.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleInfo.PathToFile.get -> string! +CheatEngine.SDK.Engine.Inspection.ModuleInfo.PathToFile.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleName +CheatEngine.SDK.Engine.Inspection.ModuleName.Equals(CheatEngine.SDK.Engine.Inspection.ModuleName other) -> bool +CheatEngine.SDK.Engine.Inspection.ModuleName.ModuleName() -> void +CheatEngine.SDK.Engine.Inspection.ModuleName.ModuleName(string! value) -> void +CheatEngine.SDK.Engine.Inspection.ModuleName.Value.get -> string! +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Address.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Deconstruct(out string! Name, out CheatEngine.SDK.Engine.Inspection.MemorySize Size, out CheatEngine.SDK.Engine.Values.Address Address, out CheatEngine.SDK.Engine.Inspection.ModuleFileOffset FileOffset) -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Equals(CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo other) -> bool +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.FileOffset.get -> CheatEngine.SDK.Engine.Inspection.ModuleFileOffset +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.FileOffset.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.ModuleSectionInfo() -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.ModuleSectionInfo(string! Name, CheatEngine.SDK.Engine.Inspection.MemorySize Size, CheatEngine.SDK.Engine.Values.Address Address, CheatEngine.SDK.Engine.Inspection.ModuleFileOffset FileOffset) -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Name.get -> string! +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Name.init -> void +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Size.get -> CheatEngine.SDK.Engine.Inspection.MemorySize +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Size.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolExpression +CheatEngine.SDK.Engine.Inspection.SymbolExpression.Equals(CheatEngine.SDK.Engine.Inspection.SymbolExpression other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolExpression.SymbolExpression() -> void +CheatEngine.SDK.Engine.Inspection.SymbolExpression.SymbolExpression(string! value) -> void +CheatEngine.SDK.Engine.Inspection.SymbolExpression.Value.get -> string! +CheatEngine.SDK.Engine.Inspection.SymbolInfo +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Address.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Deconstruct(out string! ModuleName, out string! SearchKey, out CheatEngine.SDK.Engine.Values.Address Address, out CheatEngine.SDK.Engine.Inspection.MemorySize Size) -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Equals(CheatEngine.SDK.Engine.Inspection.SymbolInfo other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolInfo.ModuleName.get -> string! +CheatEngine.SDK.Engine.Inspection.SymbolInfo.ModuleName.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.SearchKey.get -> string! +CheatEngine.SDK.Engine.Inspection.SymbolInfo.SearchKey.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Size.get -> CheatEngine.SDK.Engine.Inspection.MemorySize +CheatEngine.SDK.Engine.Inspection.SymbolInfo.Size.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.SymbolInfo() -> void +CheatEngine.SDK.Engine.Inspection.SymbolInfo.SymbolInfo(string! ModuleName, string! SearchKey, CheatEngine.SDK.Engine.Values.Address Address, CheatEngine.SDK.Engine.Inspection.MemorySize Size) -> void +CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Inspection.TargetProcessId.Equals(CheatEngine.SDK.Engine.Inspection.TargetProcessId other) -> bool +CheatEngine.SDK.Engine.Inspection.TargetProcessId.TargetProcessId() -> void +CheatEngine.SDK.Engine.Inspection.TargetProcessId.TargetProcessId(int value) -> void +CheatEngine.SDK.Engine.Inspection.TargetProcessId.Value.get -> int +CheatEngine.SDK.Engine.Memory.HostAddress +CheatEngine.SDK.Engine.Memory.HostAddress.Equals(CheatEngine.SDK.Engine.Memory.HostAddress other) -> bool +CheatEngine.SDK.Engine.Memory.HostAddress.HostAddress() -> void +CheatEngine.SDK.Engine.Memory.HostAddress.HostAddress(nuint value) -> void +CheatEngine.SDK.Engine.Memory.HostAddress.IsZero.get -> bool +CheatEngine.SDK.Engine.Memory.HostAddress.ToInt64() -> long +CheatEngine.SDK.Engine.Memory.HostAddress.ToString(string? format, System.IFormatProvider? formatProvider) -> string! +CheatEngine.SDK.Engine.Memory.HostAddress.Value.get -> nuint +CheatEngine.SDK.Engine.Memory.HostMemory +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall = 4 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.GlobalUnavailable = 1 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult = 6 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.LuaError = 2 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.None = 0 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.ReadFailed = 3 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed = 5 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.TargetMemory +CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.CEObject.CEObject() -> void +CheatEngine.SDK.Engine.Objects.CEObject.CEObject(nint value) -> void +CheatEngine.SDK.Engine.Objects.CEObject.Equals(CheatEngine.SDK.Engine.Objects.CEObject other) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.CEObject.IsNull.get -> bool +CheatEngine.SDK.Engine.Objects.CEObject.Push(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Engine.Objects.CEObject.TryCallMethod(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name, int argumentCount, int resultCount) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryCallMethod(System.ReadOnlySpan name) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.TryCallMethod(System.ReadOnlySpan name, out TResult result) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.TryGetIndex(CheatEngine.SDK.Lua.State.LuaState state, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryGetProperty(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryGetProperty(System.ReadOnlySpan name, out TValue value) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.TryGetPropertyLeavingObject(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryPushMethod(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TryPushMethodLeavingObject(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TrySetIndex(CheatEngine.SDK.Lua.State.LuaState state, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TrySetProperty(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.CEObject.TrySetProperty(System.ReadOnlySpan name, TValue value) -> bool +CheatEngine.SDK.Engine.Objects.CEObject.Value.get -> nint +CheatEngine.SDK.Engine.Objects.ICEObject +CheatEngine.SDK.Engine.Objects.ICEObject.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> TSelf +CheatEngine.SDK.Engine.Objects.ICEObject.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.Owned +CheatEngine.SDK.Engine.Objects.Owned.Abandon() -> T +CheatEngine.SDK.Engine.Objects.Owned.Dispose() -> void +CheatEngine.SDK.Engine.Objects.Owned.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.Owned.IsDisposed.get -> bool +CheatEngine.SDK.Engine.Objects.Owned.ToBorrowed() -> T +CheatEngine.SDK.Engine.Objects.Owned.Transfer() -> CheatEngine.SDK.Engine.Objects.Owned! +CheatEngine.SDK.Engine.Objects.Owned.TryDestroy(CheatEngine.SDK.Lua.State.LuaState state) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Objects.Owned.Value.get -> T +CheatEngine.SDK.Engine.Objects.StringList +CheatEngine.SDK.Engine.Objects.StringList.Equals(CheatEngine.SDK.Engine.Objects.StringList other) -> bool +CheatEngine.SDK.Engine.Objects.StringList.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Objects.StringList.IsNull.get -> bool +CheatEngine.SDK.Engine.Objects.StringList.StringList() -> void +CheatEngine.SDK.Engine.Objects.StringList.StringList(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.Objects.StringList.TryAdd(string! value, out int zeroBasedIndex) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryClear() -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryDelete(int zeroBasedIndex) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetCaseSensitive(out bool value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetCount(out int count) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetDuplicates(out CheatEngine.SDK.Engine.Enums.DuplicateHandling value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetItem(int zeroBasedIndex, out string! value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetSorted(out bool value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryGetText(out string! text) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TryIndexOf(string! value, out int zeroBasedIndex) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetCaseSensitive(bool value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetDuplicates(CheatEngine.SDK.Engine.Enums.DuplicateHandling value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetItem(int zeroBasedIndex, string! value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetSorted(bool value) -> bool +CheatEngine.SDK.Engine.Objects.StringList.TrySetText(string! text) -> bool +CheatEngine.SDK.Engine.Objects.StringLists +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.Arm32 = 3 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.Arm64 = 4 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.X64 = 2 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture.X86 = 1 -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Build.get -> int +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.CheatEngineVersion() -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.CheatEngineVersion(int major, int minor, int release, int build) -> void +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.CompareTo(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion other) -> int +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Equals(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion other) -> bool +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Major.get -> int +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Minor.get -> int +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Release.get -> int +CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Runtime.PointerSize.Bits.get -> int +CheatEngine.SDK.Engine.Runtime.PointerSize.Bytes.get -> int +CheatEngine.SDK.Engine.Runtime.PointerSize.Equals(CheatEngine.SDK.Engine.Runtime.PointerSize other) -> bool +CheatEngine.SDK.Engine.Runtime.PointerSize.IsKnown.get -> bool +CheatEngine.SDK.Engine.Runtime.PointerSize.PointerSize() -> void +CheatEngine.SDK.Engine.Runtime.PointerSize.PointerSize(int bytes) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.Any = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.Arm32 = 4 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.Arm64 = 5 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.X64 = 3 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement.X86 = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope.CheatEngine = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope.Target = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Count.get -> int +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Entries.get -> System.ReadOnlySpan +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Equals(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities? other) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.GetState(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId capability) -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.TryGet(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId capability, out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability availability) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Capability.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Capability.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Contract.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Contract.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Deconstruct(out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId Capability, out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState State, out CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract Contract) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Equals(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability other) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.IsAvailable.get -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.IsKnown.get -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.RuntimeCapabilityAvailability() -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.RuntimeCapabilityAvailability(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId Capability, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState State, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract Contract) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.State.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.State.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState.Available = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState.Unavailable = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailabilityState +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ArchitectureRequirement.get -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ArchitectureRequirement.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ArchitectureScope.get -> CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ArchitectureScope.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Deconstruct(out CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? MinimumCheatEngineVersion, out CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope ArchitectureScope, out CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement ArchitectureRequirement, out CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement ThreadRequirement, out CheatEngine.SDK.Engine.Runtime.RuntimeOwnership Ownership, out CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics ReturnSemantics) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Equals(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract other) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.MinimumCheatEngineVersion.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.MinimumCheatEngineVersion.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Ownership.get -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Ownership.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ReturnSemantics.get -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ReturnSemantics.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.RuntimeCapabilityContract() -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.RuntimeCapabilityContract(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion? MinimumCheatEngineVersion, CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureScope ArchitectureScope, CheatEngine.SDK.Engine.Runtime.RuntimeArchitectureRequirement ArchitectureRequirement, CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement ThreadRequirement, CheatEngine.SDK.Engine.Runtime.RuntimeOwnership Ownership, CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics ReturnSemantics) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ThreadRequirement.get -> CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ThreadRequirement.init -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.Equals(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId other) -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.IsEmpty.get -> bool +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.RuntimeCapabilityId() -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.RuntimeCapabilityId(string! value) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.Value.get -> string! +CheatEngine.SDK.Engine.Runtime.RuntimeInfo +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.Capabilities.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.PointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.RuntimeInfo(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion version, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture systemArchitecture, CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture targetArchitecture, CheatEngine.SDK.Engine.Runtime.PointerSize pointerSize, CheatEngine.SDK.Engine.Runtime.TargetAbi targetAbi, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! capabilities) -> void +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.RuntimeInfo.Version.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership.Borrowed = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership.None = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership.Owned = 3 -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeOwnership.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeOwnership +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.BooleanStatus = 3 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.OptionalValue = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.Value = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics.Void = 4 -> CheatEngine.SDK.Engine.Runtime.RuntimeReturnSemantics +CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement.AnyThread = 1 -> CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement.MainThread = 2 -> CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.RuntimeThreadRequirement +CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.TargetAbi.Unix = 2 -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.TargetAbi.Unknown = 0 -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Runtime.TargetAbi.Windows = 1 -> CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.AlignmentMethod.get -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.AlignmentParameter.get -> string? +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.AobScanOptions() -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.AobScanOptions(string? protectionFlags, CheatEngine.SDK.Engine.Enums.FastScanMethod alignmentMethod, string? alignmentParameter) -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.Equals(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions other) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.ProtectionFlags.get -> string? +CheatEngine.SDK.Engine.Scanning.Aob.AobScanner +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.AlignmentParameter.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.FastScanMethod.get -> CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.FirstScanRequest() -> void +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.FirstScanRequest(CheatEngine.SDK.Engine.Enums.ScanOption scanOption, CheatEngine.SDK.Engine.Enums.VariableType variableType, CheatEngine.SDK.Engine.Enums.RoundingType roundingType, string! input1, string! input2, CheatEngine.SDK.Engine.Values.Address startAddress, CheatEngine.SDK.Engine.Values.Address stopAddress, string! protectionFlags, CheatEngine.SDK.Engine.Enums.FastScanMethod fastScanMethod, string! alignmentParameter, bool isHexadecimalInput, bool isNotBinaryString, bool isUnicodeScan, bool isCaseSensitive) -> void +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.Input1.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.Input2.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.IsCaseSensitive.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.IsHexadecimalInput.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.IsNotBinaryString.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.IsUnicodeScan.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.ProtectionFlags.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.RoundingType.get -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.ScanOption.get -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.StartAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.StopAddress.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.VariableType.get -> CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Scanning.Values.FoundList +CheatEngine.SDK.Engine.Scanning.Values.FoundList.Equals(CheatEngine.SDK.Engine.Scanning.Values.FoundList other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.FoundList() -> void +CheatEngine.SDK.Engine.Scanning.Values.FoundList.FoundList(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.Scanning.Values.FoundList.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Scanning.Values.FoundList.IsNull.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.TryGetAddress(int zeroBasedIndex, out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.TryGetAddressText(int zeroBasedIndex, out string? address) -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.TryGetCount(out ulong count) -> bool +CheatEngine.SDK.Engine.Scanning.Values.FoundList.TryGetValueText(int zeroBasedIndex, out string? value) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemScan +CheatEngine.SDK.Engine.Scanning.Values.MemScan.Equals(CheatEngine.SDK.Engine.Scanning.Values.MemScan other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemScan.Handle.get -> CheatEngine.SDK.Engine.Objects.CEObject +CheatEngine.SDK.Engine.Scanning.Values.MemScan.IsNull.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemScan.MemScan() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemScan.MemScan(CheatEngine.SDK.Engine.Objects.CEObject handle) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanException +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanException.FailureKind.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanException.Operation.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.LuaError = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.MissingCapability = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.UnexpectedResult = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Dispose() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Reset() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.ResultCount.get -> ulong +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Results.get -> CheatEngine.SDK.Engine.Scanning.Values.FoundList +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Scanner.get -> CheatEngine.SDK.Engine.Scanning.Values.MemScan +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.StartFirstScan(in CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest request) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.StartNextScan(in CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest request) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.State.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryGetAddress(int zeroBasedIndex, out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryGetValue(int zeroBasedIndex, out string? value) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.WaitForCompletion() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.Disposed = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.Invalidated = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.New = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.ResultsReady = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState.Scanning = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanStateException +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanStateException.Operation.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanStateException.State.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanState +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.Input1.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.Input2.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsCaseSensitive.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsHexadecimalInput.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsNotBinaryString.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsPercentageScan.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.IsUnicodeScan.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.NextScanRequest() -> void +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.NextScanRequest(CheatEngine.SDK.Engine.Enums.ScanOption scanOption, CheatEngine.SDK.Engine.Enums.RoundingType roundingType, string! input1, string! input2, bool isHexadecimalInput, bool isNotBinaryString, bool isUnicodeScan, bool isCaseSensitive, bool isPercentageScan, string? savedResultName = null) -> void +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.RoundingType.get -> CheatEngine.SDK.Engine.Enums.RoundingType +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.SavedResultName.get -> string? +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.ScanOption.get -> CheatEngine.SDK.Engine.Enums.ScanOption +CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Values.Address.Add(long offset) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Values.Address.Address() -> void +CheatEngine.SDK.Engine.Values.Address.Address(ulong value) -> void +CheatEngine.SDK.Engine.Values.Address.CompareTo(CheatEngine.SDK.Engine.Values.Address other) -> int +CheatEngine.SDK.Engine.Values.Address.CompareTo(object? obj) -> int +CheatEngine.SDK.Engine.Values.Address.Equals(CheatEngine.SDK.Engine.Values.Address other) -> bool +CheatEngine.SDK.Engine.Values.Address.IsZero.get -> bool +CheatEngine.SDK.Engine.Values.Address.Subtract(long offset) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Values.Address.ToInt64() -> long +CheatEngine.SDK.Engine.Values.Address.ToString(string? format, System.IFormatProvider? formatProvider) -> string! +CheatEngine.SDK.Engine.Values.Address.ToUInt64() -> ulong +CheatEngine.SDK.Engine.Values.Address.TryFormat(System.Span utf8Destination, out int bytesWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) -> bool +CheatEngine.SDK.Engine.Values.Address.TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) -> bool +CheatEngine.SDK.Engine.Values.Address.Value.get -> ulong +CheatEngine.SDK.Engine.Values.IndexBase +CheatEngine.SDK.Engine.Values.LuaSequence +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState) +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).RawGetSequenceItem(int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).RawSequenceCount(int tableIndex) -> int +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).RawSetSequenceItem(int tableIndex, int zeroBasedIndex) -> void +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).TryGetSequenceItem(int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Values.LuaSequence.extension(CheatEngine.SDK.Lua.State.LuaState).TrySetSequenceItem(int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +abstract CheatEngine.SDK.Engine.Errors.EngineException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +const CheatEngine.SDK.Engine.Values.IndexBase.FirstLuaKey = 1 -> long +const CheatEngine.SDK.Engine.Values.IndexBase.FirstObjectIndex = 0 -> int +override CheatEngine.SDK.Engine.AddressList.AddressList.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.AddressList.AddressList.GetHashCode() -> int +override CheatEngine.SDK.Engine.AddressList.AddressList.ToString() -> string! +override CheatEngine.SDK.Engine.AddressList.MemoryRecord.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.AddressList.MemoryRecord.GetHashCode() -> int +override CheatEngine.SDK.Engine.AddressList.MemoryRecord.ToString() -> string! +override CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.AddressList.MemoryRecordId.GetHashCode() -> int +override CheatEngine.SDK.Engine.AddressList.MemoryRecordId.ToString() -> string! +override CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.GetHashCode() -> int +override CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.GetHashCode() -> int +override CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.ToString() -> string! +override CheatEngine.SDK.Engine.Errors.EngineBindingException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineCapabilityUnavailableException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineGlobalUnavailableException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineLuaException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineMarshallingException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineOperationFailedException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.MemorySize.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.MemorySize.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.MemorySize.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.ModuleInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.ModuleName.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.ModuleName.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.ModuleName.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.SymbolExpression.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.SymbolExpression.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.SymbolExpression.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.SymbolInfo.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.TargetProcessId.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.TargetProcessId.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.TargetProcessId.ToString() -> string! +override CheatEngine.SDK.Engine.Memory.HostAddress.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Memory.HostAddress.GetHashCode() -> int +override CheatEngine.SDK.Engine.Memory.HostAddress.ToString() -> string! +override CheatEngine.SDK.Engine.Objects.CEObject.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Objects.CEObject.GetHashCode() -> int +override CheatEngine.SDK.Engine.Objects.CEObject.ToString() -> string! +override CheatEngine.SDK.Engine.Objects.Owned.ToString() -> string! +override CheatEngine.SDK.Engine.Objects.StringList.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Objects.StringList.GetHashCode() -> int +override CheatEngine.SDK.Engine.Objects.StringList.ToString() -> string! +override CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.ToString() -> string! +override CheatEngine.SDK.Engine.Runtime.PointerSize.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Runtime.PointerSize.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.GetHashCode() -> int +override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.ToString() -> string! +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.FoundList.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Scanning.Values.FoundList.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.FoundList.ToString() -> string! +override CheatEngine.SDK.Engine.Scanning.Values.MemScan.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Scanning.Values.MemScan.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.MemScan.ToString() -> string! +override CheatEngine.SDK.Engine.Values.Address.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Values.Address.GetHashCode() -> int +override CheatEngine.SDK.Engine.Values.Address.ToString() -> string! +static CheatEngine.SDK.Engine.AddressList.AddressList.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.AddressList.AddressList +static CheatEngine.SDK.Engine.AddressList.AddressList.Null.get -> CheatEngine.SDK.Engine.AddressList.AddressList +static CheatEngine.SDK.Engine.AddressList.AddressList.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.AddressList.AddressList value) -> void +static CheatEngine.SDK.Engine.AddressList.AddressList.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.AddressList.AddressList value) -> bool +static CheatEngine.SDK.Engine.AddressList.AddressList.operator !=(CheatEngine.SDK.Engine.AddressList.AddressList left, CheatEngine.SDK.Engine.AddressList.AddressList right) -> bool +static CheatEngine.SDK.Engine.AddressList.AddressList.operator ==(CheatEngine.SDK.Engine.AddressList.AddressList left, CheatEngine.SDK.Engine.AddressList.AddressList right) -> bool +static CheatEngine.SDK.Engine.AddressList.AddressListAccess.TryGetCurrent(out CheatEngine.SDK.Engine.AddressList.AddressList addressList) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.AddressList.MemoryRecord +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.Null.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecord +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.AddressList.MemoryRecord value) -> void +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.AddressList.MemoryRecord value) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.operator !=(CheatEngine.SDK.Engine.AddressList.MemoryRecord left, CheatEngine.SDK.Engine.AddressList.MemoryRecord right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecord.operator ==(CheatEngine.SDK.Engine.AddressList.MemoryRecord left, CheatEngine.SDK.Engine.AddressList.MemoryRecord right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Compare(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> int +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.AddressList.MemoryRecordId value) -> void +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.AddressList.MemoryRecordId value) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator !=(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator <(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator <=(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator ==(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator >(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.AddressList.MemoryRecordId.operator >=(CheatEngine.SDK.Engine.AddressList.MemoryRecordId left, CheatEngine.SDK.Engine.AddressList.MemoryRecordId right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.operator !=(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest left, CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.operator ==(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest left, CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.operator !=(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize left, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetAllocationSize.operator ==(CheatEngine.SDK.Engine.Allocation.TargetAllocationSize left, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize right) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.BreakpointMethod value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.BreakpointTrigger value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.ContinueMethod value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.DuplicateHandling value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.FastScanMethod value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.MemoryProtection value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.RoundingType value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.ScanOption value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.ToCEName(this CheatEngine.SDK.Engine.Enums.VariableType value) -> System.ReadOnlySpan +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.BreakpointMethod value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.BreakpointTrigger value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.ContinueMethod value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.DuplicateHandling value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.FastScanMethod value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.MemoryProtection value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.RoundingType value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.ScanOption value) -> bool +static CheatEngine.SDK.Engine.Enums.CEEnumNames.TryParseCEName(System.ReadOnlySpan ceName, out CheatEngine.SDK.Engine.Enums.VariableType value) -> bool +static CheatEngine.SDK.Engine.Enums.EnumMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, TEnum value) -> void +static CheatEngine.SDK.Engine.Enums.EnumMarshaller.ToInt64(TEnum value) -> long +static CheatEngine.SDK.Engine.Enums.EnumMarshaller.TryFromInt64(long bits, out TEnum value) -> bool +static CheatEngine.SDK.Engine.Enums.EnumMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out TEnum value) -> bool +static CheatEngine.SDK.Engine.Errors.EngineException.RequireText(string! value, string! parameterName) -> string! +static CheatEngine.SDK.Engine.Generated.MemoryScalars.TryReadInt32(CheatEngine.SDK.Engine.Values.Address address, out int value) -> bool +static CheatEngine.SDK.Engine.Generated.MemoryScalars.TryReadInt64(CheatEngine.SDK.Engine.Values.Address address, out long value) -> bool +static CheatEngine.SDK.Engine.Generated.MemoryScalars.WriteInt32(CheatEngine.SDK.Engine.Values.Address address, int value) -> bool +static CheatEngine.SDK.Engine.Generated.MemoryScalars.WriteInt64(CheatEngine.SDK.Engine.Values.Address address, long value) -> bool +static CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.operator !=(CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions left, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions right) -> bool +static CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.operator ==(CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions left, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions right) -> bool +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateMemoryRegions(System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateModules(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateModules(System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateSections(CheatEngine.SDK.Engine.Inspection.ModuleName moduleName, System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.EnumerateSections(CheatEngine.SDK.Engine.Values.Address moduleBase, System.Span destination, out int written) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.GetMemoryRegionInfo(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo region) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.GetSymbolInfo(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, out CheatEngine.SDK.Engine.Inspection.SymbolInfo symbol) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.EngineInspection.ResolveAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions options, out CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.operator !=(CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo left, CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.operator ==(CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo left, CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator !=(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator <(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator <=(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator ==(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator >(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.MemorySize.operator >=(CheatEngine.SDK.Engine.Inspection.MemorySize left, CheatEngine.SDK.Engine.Inspection.MemorySize right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.operator !=(CheatEngine.SDK.Engine.Inspection.ModuleFileOffset left, CheatEngine.SDK.Engine.Inspection.ModuleFileOffset right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleFileOffset.operator ==(CheatEngine.SDK.Engine.Inspection.ModuleFileOffset left, CheatEngine.SDK.Engine.Inspection.ModuleFileOffset right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleInfo.operator !=(CheatEngine.SDK.Engine.Inspection.ModuleInfo left, CheatEngine.SDK.Engine.Inspection.ModuleInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleInfo.operator ==(CheatEngine.SDK.Engine.Inspection.ModuleInfo left, CheatEngine.SDK.Engine.Inspection.ModuleInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleName.operator !=(CheatEngine.SDK.Engine.Inspection.ModuleName left, CheatEngine.SDK.Engine.Inspection.ModuleName right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleName.operator ==(CheatEngine.SDK.Engine.Inspection.ModuleName left, CheatEngine.SDK.Engine.Inspection.ModuleName right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.operator !=(CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo left, CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.operator ==(CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo left, CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolExpression.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolExpression left, CheatEngine.SDK.Engine.Inspection.SymbolExpression right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolExpression.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolExpression left, CheatEngine.SDK.Engine.Inspection.SymbolExpression right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolInfo.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolInfo left, CheatEngine.SDK.Engine.Inspection.SymbolInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolInfo.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolInfo left, CheatEngine.SDK.Engine.Inspection.SymbolInfo right) -> bool +static CheatEngine.SDK.Engine.Inspection.TargetProcessId.operator !=(CheatEngine.SDK.Engine.Inspection.TargetProcessId left, CheatEngine.SDK.Engine.Inspection.TargetProcessId right) -> bool +static CheatEngine.SDK.Engine.Inspection.TargetProcessId.operator ==(CheatEngine.SDK.Engine.Inspection.TargetProcessId left, CheatEngine.SDK.Engine.Inspection.TargetProcessId right) -> bool +static CheatEngine.SDK.Engine.Memory.HostAddress.FromInt64(long value) -> CheatEngine.SDK.Engine.Memory.HostAddress +static CheatEngine.SDK.Engine.Memory.HostAddress.Zero.get -> CheatEngine.SDK.Engine.Memory.HostAddress +static CheatEngine.SDK.Engine.Memory.HostAddress.operator !=(CheatEngine.SDK.Engine.Memory.HostAddress left, CheatEngine.SDK.Engine.Memory.HostAddress right) -> bool +static CheatEngine.SDK.Engine.Memory.HostAddress.operator ==(CheatEngine.SDK.Engine.Memory.HostAddress left, CheatEngine.SDK.Engine.Memory.HostAddress right) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadBytes(CheatEngine.SDK.Engine.Memory.HostAddress address, System.Span destination, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadDouble(CheatEngine.SDK.Engine.Memory.HostAddress address, out double value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadInt16(CheatEngine.SDK.Engine.Memory.HostAddress address, out short value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadInt32(CheatEngine.SDK.Engine.Memory.HostAddress address, out int value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadInt64(CheatEngine.SDK.Engine.Memory.HostAddress address, out long value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadInt8(CheatEngine.SDK.Engine.Memory.HostAddress address, out sbyte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadPointer(CheatEngine.SDK.Engine.Memory.HostAddress address, out CheatEngine.SDK.Engine.Memory.HostAddress value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadSingle(CheatEngine.SDK.Engine.Memory.HostAddress address, out float value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadString(CheatEngine.SDK.Engine.Memory.HostAddress address, int maximumLength, bool wideCharacter, out string? value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUInt16(CheatEngine.SDK.Engine.Memory.HostAddress address, out ushort value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUInt32(CheatEngine.SDK.Engine.Memory.HostAddress address, out uint value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUInt64(CheatEngine.SDK.Engine.Memory.HostAddress address, out ulong value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUInt8(CheatEngine.SDK.Engine.Memory.HostAddress address, out byte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUtf8(CheatEngine.SDK.Engine.Memory.HostAddress address, int maximumLength, System.Span destination, bool wideCharacter, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteBytes(CheatEngine.SDK.Engine.Memory.HostAddress address, System.ReadOnlySpan value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteDouble(CheatEngine.SDK.Engine.Memory.HostAddress address, double value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteInt16(CheatEngine.SDK.Engine.Memory.HostAddress address, short value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteInt32(CheatEngine.SDK.Engine.Memory.HostAddress address, int value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteInt64(CheatEngine.SDK.Engine.Memory.HostAddress address, long value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteInt8(CheatEngine.SDK.Engine.Memory.HostAddress address, sbyte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWritePointer(CheatEngine.SDK.Engine.Memory.HostAddress address, CheatEngine.SDK.Engine.Memory.HostAddress value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteSingle(CheatEngine.SDK.Engine.Memory.HostAddress address, float value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteString(CheatEngine.SDK.Engine.Memory.HostAddress address, System.ReadOnlySpan value, bool wideCharacter, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUInt16(CheatEngine.SDK.Engine.Memory.HostAddress address, ushort value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUInt32(CheatEngine.SDK.Engine.Memory.HostAddress address, uint value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUInt64(CheatEngine.SDK.Engine.Memory.HostAddress address, ulong value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUInt8(CheatEngine.SDK.Engine.Memory.HostAddress address, byte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteUtf8(CheatEngine.SDK.Engine.Memory.HostAddress address, System.ReadOnlySpan value, bool wideCharacter, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadBytes(CheatEngine.SDK.Engine.Values.Address address, System.Span destination, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadDouble(CheatEngine.SDK.Engine.Values.Address address, out double value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadInt16(CheatEngine.SDK.Engine.Values.Address address, out short value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadInt32(CheatEngine.SDK.Engine.Values.Address address, out int value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadInt64(CheatEngine.SDK.Engine.Values.Address address, out long value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadInt8(CheatEngine.SDK.Engine.Values.Address address, out sbyte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadPointer(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Values.Address value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadSingle(CheatEngine.SDK.Engine.Values.Address address, out float value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadString(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, bool wideCharacter, out string? value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUInt16(CheatEngine.SDK.Engine.Values.Address address, out ushort value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUInt32(CheatEngine.SDK.Engine.Values.Address address, out uint value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUInt64(CheatEngine.SDK.Engine.Values.Address address, out ulong value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUInt8(CheatEngine.SDK.Engine.Values.Address address, out byte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUtf8(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, System.Span destination, bool wideCharacter, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteBytes(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteDouble(CheatEngine.SDK.Engine.Values.Address address, double value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteInt16(CheatEngine.SDK.Engine.Values.Address address, short value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteInt32(CheatEngine.SDK.Engine.Values.Address address, int value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteInt64(CheatEngine.SDK.Engine.Values.Address address, long value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteInt8(CheatEngine.SDK.Engine.Values.Address address, sbyte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWritePointer(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Values.Address value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteSingle(CheatEngine.SDK.Engine.Values.Address address, float value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteString(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan value, bool wideCharacter, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUInt16(CheatEngine.SDK.Engine.Values.Address address, ushort value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUInt32(CheatEngine.SDK.Engine.Values.Address address, uint value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUInt64(CheatEngine.SDK.Engine.Values.Address address, ulong value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUInt8(CheatEngine.SDK.Engine.Values.Address address, byte value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteUtf8(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan value, bool wideCharacter, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Objects.CEObject.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Objects.CEObject +static CheatEngine.SDK.Engine.Objects.CEObject.Null.get -> CheatEngine.SDK.Engine.Objects.CEObject +static CheatEngine.SDK.Engine.Objects.CEObject.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Objects.CEObject value) -> void +static CheatEngine.SDK.Engine.Objects.CEObject.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Objects.CEObject value) -> bool +static CheatEngine.SDK.Engine.Objects.CEObject.operator !=(CheatEngine.SDK.Engine.Objects.CEObject left, CheatEngine.SDK.Engine.Objects.CEObject right) -> bool +static CheatEngine.SDK.Engine.Objects.CEObject.operator ==(CheatEngine.SDK.Engine.Objects.CEObject left, CheatEngine.SDK.Engine.Objects.CEObject right) -> bool +static CheatEngine.SDK.Engine.Objects.StringList.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Objects.StringList +static CheatEngine.SDK.Engine.Objects.StringList.Null.get -> CheatEngine.SDK.Engine.Objects.StringList +static CheatEngine.SDK.Engine.Objects.StringList.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Objects.StringList value) -> void +static CheatEngine.SDK.Engine.Objects.StringList.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Objects.StringList value) -> bool +static CheatEngine.SDK.Engine.Objects.StringList.operator !=(CheatEngine.SDK.Engine.Objects.StringList left, CheatEngine.SDK.Engine.Objects.StringList right) -> bool +static CheatEngine.SDK.Engine.Objects.StringList.operator ==(CheatEngine.SDK.Engine.Objects.StringList left, CheatEngine.SDK.Engine.Objects.StringList right) -> bool +static CheatEngine.SDK.Engine.Objects.StringLists.TryCreate(out CheatEngine.SDK.Engine.Objects.Owned? list) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.Ce77010621.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator !=(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator <(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator <=(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator ==(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator >(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.CheatEngineVersion.operator >=(CheatEngine.SDK.Engine.Runtime.CheatEngineVersion left, CheatEngine.SDK.Engine.Runtime.CheatEngineVersion right) -> bool +static CheatEngine.SDK.Engine.Runtime.PointerSize.Bit32.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +static CheatEngine.SDK.Engine.Runtime.PointerSize.Bit64.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +static CheatEngine.SDK.Engine.Runtime.PointerSize.FromArchitecture(CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture architecture) -> CheatEngine.SDK.Engine.Runtime.PointerSize +static CheatEngine.SDK.Engine.Runtime.PointerSize.Unknown.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +static CheatEngine.SDK.Engine.Runtime.PointerSize.operator !=(CheatEngine.SDK.Engine.Runtime.PointerSize left, CheatEngine.SDK.Engine.Runtime.PointerSize right) -> bool +static CheatEngine.SDK.Engine.Runtime.PointerSize.operator ==(CheatEngine.SDK.Engine.Runtime.PointerSize left, CheatEngine.SDK.Engine.Runtime.PointerSize right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Create(System.ReadOnlySpan entries) -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities.Empty.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities! +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.operator !=(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.operator ==(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Unknown.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.operator !=(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.operator ==(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.CheatEngineVersion.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.SystemArchitecture.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.TargetAbi.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.TargetArchitecture.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.operator !=(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.operator ==(CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId left, CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId right) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TryDecodeSystemArchitecture(int code, out CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture architecture) -> bool +static CheatEngine.SDK.Engine.Runtime.RuntimeInfo.TryDecodeTargetAbi(int code, out CheatEngine.SDK.Engine.Runtime.TargetAbi abi) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.Default.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.operator !=(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions.operator ==(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScan(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, out CheatEngine.SDK.Engine.Objects.Owned? results) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScan(string! pattern, out CheatEngine.SDK.Engine.Objects.Owned? results) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest.ExactValue(CheatEngine.SDK.Engine.Enums.VariableType variableType, string! input) -> CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest +static CheatEngine.SDK.Engine.Scanning.Values.FoundList.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Scanning.Values.FoundList +static CheatEngine.SDK.Engine.Scanning.Values.FoundList.operator !=(CheatEngine.SDK.Engine.Scanning.Values.FoundList left, CheatEngine.SDK.Engine.Scanning.Values.FoundList right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.FoundList.operator ==(CheatEngine.SDK.Engine.Scanning.Values.FoundList left, CheatEngine.SDK.Engine.Scanning.Values.FoundList right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemScan.FromHandle(CheatEngine.SDK.Engine.Objects.CEObject handle) -> CheatEngine.SDK.Engine.Scanning.Values.MemScan +static CheatEngine.SDK.Engine.Scanning.Values.MemScan.operator !=(CheatEngine.SDK.Engine.Scanning.Values.MemScan left, CheatEngine.SDK.Engine.Scanning.Values.MemScan right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemScan.operator ==(CheatEngine.SDK.Engine.Scanning.Values.MemScan left, CheatEngine.SDK.Engine.Scanning.Values.MemScan right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Adopt(CheatEngine.SDK.Engine.Objects.Owned! scanner, CheatEngine.SDK.Engine.Objects.Owned! foundList) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession! +static CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest.ExactValue(string! input) -> CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest +static CheatEngine.SDK.Engine.Values.Address.FromInt64(long bits) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.FromUInt64(ulong value) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.Parse(System.ReadOnlySpan text) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.Parse(string! text) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Values.Address value) -> void +static CheatEngine.SDK.Engine.Values.Address.TryParse(System.ReadOnlySpan utf8, out CheatEngine.SDK.Engine.Values.Address address) -> bool +static CheatEngine.SDK.Engine.Values.Address.TryParse(System.ReadOnlySpan text, out CheatEngine.SDK.Engine.Values.Address address) -> bool +static CheatEngine.SDK.Engine.Values.Address.TryParse(string? text, out CheatEngine.SDK.Engine.Values.Address address) -> bool +static CheatEngine.SDK.Engine.Values.Address.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Values.Address value) -> bool +static CheatEngine.SDK.Engine.Values.Address.Zero.get -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.explicit operator ulong(CheatEngine.SDK.Engine.Values.Address address) -> ulong +static CheatEngine.SDK.Engine.Values.Address.implicit operator CheatEngine.SDK.Engine.Values.Address(ulong value) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.operator !=(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator +(CheatEngine.SDK.Engine.Values.Address address, long offset) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.operator -(CheatEngine.SDK.Engine.Values.Address address, long offset) -> CheatEngine.SDK.Engine.Values.Address +static CheatEngine.SDK.Engine.Values.Address.operator <(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator <=(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator ==(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator >(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.Address.operator >=(CheatEngine.SDK.Engine.Values.Address left, CheatEngine.SDK.Engine.Values.Address right) -> bool +static CheatEngine.SDK.Engine.Values.IndexBase.FromLuaKey(long luaKey) -> int +static CheatEngine.SDK.Engine.Values.IndexBase.ToLuaKey(int zeroBasedIndex) -> long +static CheatEngine.SDK.Engine.Values.IndexBase.TryFromLuaKey(long luaKey, out int zeroBasedIndex) -> bool +static CheatEngine.SDK.Engine.Values.LuaSequence.RawGetSequenceItem(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.State.LuaType +static CheatEngine.SDK.Engine.Values.LuaSequence.RawSequenceCount(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex) -> int +static CheatEngine.SDK.Engine.Values.LuaSequence.RawSetSequenceItem(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex, int zeroBasedIndex) -> void +static CheatEngine.SDK.Engine.Values.LuaSequence.TryGetSequenceItem(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Engine.Values.LuaSequence.TrySetSequenceItem(this CheatEngine.SDK.Lua.State.LuaState state, int tableIndex, int zeroBasedIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +~override CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.ModuleInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.ModuleInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.SymbolInfo.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.SymbolInfo.ToString() -> string +~override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability.ToString() -> string +~override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityContract.ToString() -> string diff --git a/libs/CheatEngine.SDK.Engine/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Engine/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..b03460be --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/PublicAPI.Unshipped.txt @@ -0,0 +1,521 @@ +#nullable enable +*REMOVED*CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.AddressResolutionOptions(bool UseHostSymbolTable = false, bool Shallow = false) -> void +*REMOVED*CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Deconstruct(out bool UseHostSymbolTable, out bool Shallow) -> void +*REMOVED*CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.UseHostSymbolTable.get -> bool +*REMOVED*CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.UseHostSymbolTable.init -> void +*REMOVED*CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall = 4 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +*REMOVED*CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult = 6 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +*REMOVED*CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed = 5 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.AddressList.AddressListMutations +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect.Completed = 1 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect.Indeterminate = 2 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect.NotAttempted = 0 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.Effect.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.IsCompleted.get -> bool +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.MemoryRecordMutationOutcome() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome.Problem.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.AddressListUnavailable = 2 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.CycleDetected = 6 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.GlobalUnavailable = 8 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.InvalidResult = 10 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.LuaFailure = 9 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.None = 1 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.ParentNotFound = 4 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.RecordNotFound = 3 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.SelfParent = 5 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.TraversalLimitReached = 7 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem.Uninitialized = 0 -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem +CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit +CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit.MaximumHops.get -> int +CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit.MemoryRecordParentTraversalLimit() -> void +CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit.MemoryRecordParentTraversalLimit(int maximumHops) -> void +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.LastReleaseOutcome.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.ReleaseWithOutcome() -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.ReleaseWithTargetOutcome() -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Allocation.AllocatedRegion.TargetIncarnation.get -> CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation +CheatEngine.SDK.Engine.Allocation.ITargetBoundMemoryAllocationOperations +CheatEngine.SDK.Engine.Allocation.ITargetBoundMemoryAllocationOperations.AllocateBoundWithOutcome(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request, out CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation incarnation, out CheatEngine.SDK.Engine.Targets.TargetSelectionObservation observation) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +CheatEngine.SDK.Engine.Allocation.ITargetBoundMemoryAllocationOperations.DeallocateBoundWithOutcome(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation expected, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size, out CheatEngine.SDK.Engine.Targets.TargetIdentityCheck targetCheck) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.ITargetBoundMemoryAllocationOperations.TryDeallocateBound(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation expected, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size, out CheatEngine.SDK.Engine.Targets.TargetIdentityCheck targetCheck) -> bool +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOutcomeOperations +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOutcomeOperations.AllocateWithOutcome(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +CheatEngine.SDK.Engine.Allocation.ITargetMemoryAllocationOutcomeOperations.DeallocateWithOutcome(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.LuaTargetMemoryAllocationOperations +CheatEngine.SDK.Engine.Allocation.LuaTargetMemoryAllocationOperations.AllocateWithOutcome(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +CheatEngine.SDK.Engine.Allocation.LuaTargetMemoryAllocationOperations.DeallocateWithOutcome(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.LuaTargetMemoryAllocationOperations.TryAllocate(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request, out CheatEngine.SDK.Engine.Values.Address address) -> bool +CheatEngine.SDK.Engine.Allocation.LuaTargetMemoryAllocationOperations.TryDeallocate(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Allocation.TargetAllocationSize size) -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Equals(CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome other) -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Operation.get -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.TargetMemoryAllocationOutcome() -> void +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator.AllocateWithOutcome(CheatEngine.SDK.Engine.Allocation.TargetAllocationRequest request) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocator.TargetMemoryAllocator() -> void +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Equals(CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome other) -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.FailureKind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind? +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.IsExpectedFailure.get -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Kind.get -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.TargetMemoryOperationOutcome() -> void +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.BindingFailure = 6 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.CapabilityUnavailable = 4 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.ExpectedFailure = 2 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.GlobalUnavailable = 3 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.MarshallingFailure = 7 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.ProtectedLuaFailure = 5 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.Succeeded = 1 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.TargetIdentityMismatch = 9 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.TargetIdentityUnavailable = 8 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind.Unspecified = 0 -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.Dispose() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.IsDisposed.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.IsEnabled.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.LastReleaseOutcome.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.Release() -> void +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.ReleaseWithTargetOutcome() -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.RequiresManualRecovery.get -> bool +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch.TargetIncarnation.get -> CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation +CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher +CheatEngine.SDK.Engine.Assembly.InstructionAssembler +CheatEngine.SDK.Engine.Assembly.InstructionDisassembler +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Address.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.AddressText.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.AddressText.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Bytes.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Bytes.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Deconstruct(out CheatEngine.SDK.Engine.Values.Address Address, out string! AddressText, out string! Bytes, out string! Opcode, out string! Extra, out int Utf8ByteLength) -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Equals(CheatEngine.SDK.Engine.Assembly.InstructionDisassembly other) -> bool +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Extra.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Extra.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.InstructionDisassembly() -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.InstructionDisassembly(CheatEngine.SDK.Engine.Values.Address Address, string! AddressText, string! Bytes, string! Opcode, string! Extra, int Utf8ByteLength) -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Opcode.get -> string! +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Opcode.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Utf8ByteLength.get -> int +CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Utf8ByteLength.init -> void +CheatEngine.SDK.Engine.Assembly.InstructionNavigator +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.AddressExceedsProfileWidth = 2 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.DestinationTooSmall = 5 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.GlobalUnavailable = 8 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.InstructionRejected = 7 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.InvalidProfile = 1 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.InvalidResult = 10 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.LuaFailure = 9 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.OutputTooLong = 6 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.Success = 0 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.TargetChanged = 4 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus.TargetNotSelected = 3 -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +CheatEngine.SDK.Engine.Assembly.InstructionProfile +CheatEngine.SDK.Engine.Assembly.InstructionProfile.AddressWidth.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Assembly.InstructionProfile.Architecture.get -> CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Assembly.InstructionProfile.Equals(CheatEngine.SDK.Engine.Assembly.InstructionProfile other) -> bool +CheatEngine.SDK.Engine.Assembly.InstructionProfile.InstructionProfile() -> void +CheatEngine.SDK.Engine.Assembly.InstructionProfile.IsValid.get -> bool +CheatEngine.SDK.Engine.Assembly.InstructionProfiles +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.Equals(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile other) -> bool +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.InstructionTargetProfile() -> void +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.Profile.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.Target.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Errors.EngineFailureKind.TargetIdentityMismatch = 7 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineFailureKind.TargetIdentityUnavailable = 6 -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException +CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException.CleanupOutcome.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException.EngineResourceHandoffException(string! operation, CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome cleanupOutcome, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException.Operation.get -> string! +CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException +CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException.Check.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck +CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException.EngineTargetIdentityException(string! operation, CheatEngine.SDK.Engine.Targets.TargetIdentityCheck check) -> void +CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException.Operation.get -> string! +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.AddressResolutionOptions(bool Shallow = false) -> void +CheatEngine.SDK.Engine.Inspection.SymbolName +CheatEngine.SDK.Engine.Inspection.SymbolName.Equals(CheatEngine.SDK.Engine.Inspection.SymbolName other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolName.SymbolName() -> void +CheatEngine.SDK.Engine.Inspection.SymbolName.SymbolName(string! value) -> void +CheatEngine.SDK.Engine.Inspection.SymbolName.Value.get -> string! +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome.HasLease.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome.Lease.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease? +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome.Status.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome.SymbolRegistrationAcquireOutcome() -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException.CleanupOutcome.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException.SymbolRegistrationHandoffException(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome cleanupOutcome, System.Exception? innerException) -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Dispose() -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.IsTerminal.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Name.get -> CheatEngine.SDK.Engine.Inspection.SymbolName +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Options.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationLease.Release() -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.Deconstruct(out bool DoNotSave) -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.DoNotSave.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.DoNotSave.init -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.Equals(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions other) -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.SymbolRegistrationOptions() -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.SymbolRegistrationOptions(bool DoNotSave = false) -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.AlreadyReleased = 1 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.CleanupIndeterminate = 5 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.CleanupUnavailable = 4 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.Released = 0 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.StaleRuntime = 3 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind.Superseded = 2 -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome.IsTerminal.get -> bool +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome.Kind.get -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome.Status.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseOutcome.SymbolRegistrationReleaseOutcome() -> void +CheatEngine.SDK.Engine.Inspection.SymbolRegistry +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall = 5 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult = 9 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.PartialRead = 4 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.PointerValueExceedsTargetWidth = 7 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.PointerWidthUnknown = 6 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed = 8 -> CheatEngine.SDK.Engine.Memory.MemoryAccessFailure +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.CurrentProcessObservation() -> void +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.CurrentProcessObservation(CheatEngine.SDK.Engine.Inspection.TargetProcessId Id, CheatEngine.SDK.Engine.Runtime.PointerSize PointerSize) -> void +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Deconstruct(out CheatEngine.SDK.Engine.Inspection.TargetProcessId Id, out CheatEngine.SDK.Engine.Runtime.PointerSize PointerSize) -> void +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Equals(CheatEngine.SDK.Engine.Processes.CurrentProcessObservation other) -> bool +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Id.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Id.init -> void +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.PointerSize.get -> CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.PointerSize.init -> void +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.Equals(CheatEngine.SDK.Engine.Processes.ProcessOperationStatus other) -> bool +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.Kind.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.ProcessOperationStatus() -> void +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.GlobalUnavailable = 3 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.InvalidResult = 5 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.ProtectedLuaFailure = 4 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.SelectionNotConfirmed = 2 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.Success = 0 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind.TargetNotAttached = 1 -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind +CheatEngine.SDK.Engine.Processes.RuntimeHostOperations +CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations +CheatEngine.SDK.Engine.Runtime.PointerSize.TryReadLittleEndian(System.ReadOnlySpan source, out ulong value) -> bool +CheatEngine.SDK.Engine.Runtime.PointerSize.TryWriteLittleEndian(ulong value, System.Span destination) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.AobScanOutcome() -> void +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.Equals(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome other) -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.HasResultCount.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.IsSuccess.get -> bool +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.Kind.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.ResultCount.get -> int +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.GlobalUnavailable = 3 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.InvalidResult = 6 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.Matches = 1 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.NoMatches = 2 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.NoResult = 5 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.ProtectedLuaFailure = 4 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.ResultListCountUnavailable = 7 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind.Unknown = 0 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.GlobalUnavailable = 1 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.InvalidResult = 4 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.LuaFailure = 2 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.NoResult = 3 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus.Success = 0 -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone.CancelledBeforeNativeCall = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone.None = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone.ObservedAfterNativeCall = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.Equals(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.MemoryScanCreationOutcome() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.Status.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.TargetObservation.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.AliasedFoundList = 7 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.GlobalUnavailable = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.InvalidFoundListResult = 6 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.InvalidScannerResult = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.LuaFailure = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.NoFoundListResult = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.NoScannerResult = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.RollbackUnconfirmed = 8 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.Success = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus.TargetIdentityUnavailable = 9 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.RuntimeInvalidated = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.TargetIdentityMismatch = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind.TargetIdentityUnavailable = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanFailureKind +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.None = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.ProtectedLuaFailure = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.RuntimeIdentityChanged = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.TargetChanged = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason.TargetProcessReused = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.Cancelled = 3 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.DestinationTooSmall = 2 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.InvalidResult = 8 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.LuaFailure = 7 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.NoResults = 1 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.PageStartOutOfRange = 9 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.RuntimeInvalidated = 4 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.Success = 0 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.TargetIdentityMismatch = 6 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus.TargetIdentityUnavailable = 5 -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.Equals(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.FoundList.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.FoundListOwnershipConsumed.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.MemScan.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.MemScanOwnershipConsumed.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.MemoryScanReleaseOutcome() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.OwnershipConsumed.get -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Address.init -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Deconstruct(out CheatEngine.SDK.Engine.Values.Address Address, out string! Value) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Equals(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult other) -> bool +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.MemoryScanResult() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.MemoryScanResult(CheatEngine.SDK.Engine.Values.Address Address, string! Value) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Value.get -> string! +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Value.init -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Abandon() -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.InvalidationReason.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.LastCancellationMilestone.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.LastReleaseOutcome.get -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.LastTargetCheck.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck? +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.ReleaseWithOutcome() -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.ResetCancellable(System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.RuntimeIdentity.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.StartFirstScanCancellable(in CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest request, System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.StartNextScanCancellable(in CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest request, System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TargetObservation.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryCopyResults(System.Span destination, out ulong totalCount, out int written) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryCopyResultsCancellable(System.Span destination, out ulong totalCount, out int written, System.Threading.CancellationToken cancellationToken) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryCopyResultsPage(int firstResultIndex, System.Span destination, out ulong totalCount, out int written) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.TryCopyResultsPageCancellable(int firstResultIndex, System.Span destination, out ulong totalCount, out int written, System.Threading.CancellationToken cancellationToken) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.WaitForCompletionCancellable(System.Threading.CancellationToken cancellationToken) -> void +CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions +CheatEngine.SDK.Engine.Tables.CheatTableFiles +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.Equals(CheatEngine.SDK.Engine.Targets.TargetIdentityCheck other) -> bool +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.IsCurrent.get -> bool +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.Kind.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.Observed.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.TargetIdentityCheck() -> void +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.Current = 1 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.CurrentTargetUnqualified = 3 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.GlobalUnavailable = 6 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.InvalidResult = 8 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.LuaFailure = 7 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.NoTargetSelected = 2 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.ProcessReused = 5 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.TargetChanged = 4 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind.Unspecified = 0 -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence.CheatEngineSelectedProcessId = 1 -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence.LocalProcessStartTime = 2 -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence.None = 0 -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.Equals(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation other) -> bool +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.ProcessId.get -> int +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.StartedAtUtcTicks.get -> long +CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.TargetProcessIncarnation() -> void +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.Equals(CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome other) -> bool +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.FailureKind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind? +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.RequiresManualRecovery.get -> bool +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.Status.get -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.TargetCheck.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck? +CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.TargetReleaseOutcome() -> void +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.NotInvoked = 7 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedIdentityUnavailable = 3 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedNoTarget = 2 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedProcessReused = 5 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.RefusedTargetChanged = 4 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.Released = 1 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.UnconfirmedAfterInvocation = 6 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetReleaseStatus.Unspecified = 0 -> CheatEngine.SDK.Engine.Targets.TargetReleaseStatus +CheatEngine.SDK.Engine.Targets.TargetSelection +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Equals(CheatEngine.SDK.Engine.Targets.TargetSelectionObservation other) -> bool +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Evidence.get -> CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Incarnation.get -> CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation? +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.IsQualified.get -> bool +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.SelectedProcessId.get -> int? +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Status.get -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.TargetSelectionObservation() -> void +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.CurrentTargetQualified = 1 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.CurrentTargetUnqualified = 3 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.GlobalUnavailable = 4 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.InvalidResult = 6 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.LuaFailure = 5 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.NoTargetSelected = 2 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus.Unspecified = 0 -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus +override CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.InstructionProfile.GetHashCode() -> int +override CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.GetHashCode() -> int +override CheatEngine.SDK.Engine.Errors.EngineResourceHandoffException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Errors.EngineTargetIdentityException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Inspection.SymbolName.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Inspection.SymbolName.GetHashCode() -> int +override CheatEngine.SDK.Engine.Inspection.SymbolName.ToString() -> string! +override CheatEngine.SDK.Engine.Inspection.SymbolRegistrationHandoffException.Kind.get -> CheatEngine.SDK.Engine.Errors.EngineFailureKind +override CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.GetHashCode() -> int +override CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.GetHashCode() -> int +override CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.GetHashCode() -> int +override CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.GetHashCode() -> int +override CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.Equals(object? obj) -> bool +override CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.GetHashCode() -> int +override CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.GetHashCode() -> int +override CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.GetHashCode() -> int +static CheatEngine.SDK.Engine.AddressList.AddressListMutations.Delete(CheatEngine.SDK.Engine.AddressList.MemoryRecordId recordId) -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome +static CheatEngine.SDK.Engine.AddressList.AddressListMutations.SetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId recordId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId) -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome +static CheatEngine.SDK.Engine.AddressList.AddressListMutations.SetParent(CheatEngine.SDK.Engine.AddressList.MemoryRecordId recordId, CheatEngine.SDK.Engine.AddressList.MemoryRecordId? parentId, CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit traversalLimit) -> CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationOutcome +static CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit.Default.get -> CheatEngine.SDK.Engine.AddressList.MemoryRecordParentTraversalLimit +static CheatEngine.SDK.Engine.Allocation.LuaTargetMemoryAllocationOperations.Instance.get -> CheatEngine.SDK.Engine.Allocation.LuaTargetMemoryAllocationOperations! +static CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Failed(CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome operation) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +static CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Succeeded(CheatEngine.SDK.Engine.Values.Address address) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome +static CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.operator !=(CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome left, CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.operator ==(CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome left, CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Failed(CheatEngine.SDK.Engine.Errors.EngineFailureKind failureKind, CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus = default(CheatEngine.SDK.Lua.Calls.LuaStatus)) -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +static CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Succeeded() -> CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome +static CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.operator !=(CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome left, CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome right) -> bool +static CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.operator ==(CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome left, CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome right) -> bool +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher.Apply(string! script) -> CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch! +static CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatcher.TryApply(string! script, out CheatEngine.SDK.Engine.Assembly.AutoAssemblerPatch? patch) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionAssembler.TryAssemble(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, string! instruction, CheatEngine.SDK.Engine.Values.Address address, System.Span destination, out int written, out int requiredLength) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionDisassembler.TryDisassemble(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, CheatEngine.SDK.Engine.Values.Address address, int maximumUtf8Bytes, out CheatEngine.SDK.Engine.Assembly.InstructionDisassembly instruction, out int requiredUtf8Bytes) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.operator !=(CheatEngine.SDK.Engine.Assembly.InstructionDisassembly left, CheatEngine.SDK.Engine.Assembly.InstructionDisassembly right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.operator ==(CheatEngine.SDK.Engine.Assembly.InstructionDisassembly left, CheatEngine.SDK.Engine.Assembly.InstructionDisassembly right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionNavigator.TryGetLength(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, CheatEngine.SDK.Engine.Values.Address address, out int length) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionNavigator.TryGetPrevious(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile, CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Values.Address previous) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.Arm32.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.Arm64.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.X64.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.X86.get -> CheatEngine.SDK.Engine.Assembly.InstructionProfile +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.operator !=(CheatEngine.SDK.Engine.Assembly.InstructionProfile left, CheatEngine.SDK.Engine.Assembly.InstructionProfile right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionProfile.operator ==(CheatEngine.SDK.Engine.Assembly.InstructionProfile left, CheatEngine.SDK.Engine.Assembly.InstructionProfile right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionProfiles.TryObserveCurrent(out CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile targetProfile) -> CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus +static CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.operator !=(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile left, CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile right) -> bool +static CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.operator ==(CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile left, CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile right) -> bool +static CheatEngine.SDK.Engine.Inspection.EngineInspection.ResolveHostAddress(CheatEngine.SDK.Engine.Inspection.SymbolExpression expression, CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions options, out CheatEngine.SDK.Engine.Memory.HostAddress address) -> CheatEngine.SDK.Engine.Inspection.InspectionStatus +static CheatEngine.SDK.Engine.Inspection.SymbolName.Push(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Engine.Inspection.SymbolName value) -> void +static CheatEngine.SDK.Engine.Inspection.SymbolName.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out CheatEngine.SDK.Engine.Inspection.SymbolName value) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolName.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolName left, CheatEngine.SDK.Engine.Inspection.SymbolName right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolName.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolName left, CheatEngine.SDK.Engine.Inspection.SymbolName right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.operator !=(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions left, CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.operator ==(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions left, CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions right) -> bool +static CheatEngine.SDK.Engine.Inspection.SymbolRegistry.Register(CheatEngine.SDK.Engine.Inspection.SymbolName name, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions options = default(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions)) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Inspection.SymbolRegistry.TryGetName(CheatEngine.SDK.Engine.Values.Address address, out string? name) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Inspection.SymbolRegistry.TryRegisterOwned(CheatEngine.SDK.Engine.Inspection.SymbolName name, CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions options = default(CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions)) -> CheatEngine.SDK.Engine.Inspection.SymbolRegistrationAcquireOutcome +static CheatEngine.SDK.Engine.Inspection.SymbolRegistry.Unregister(CheatEngine.SDK.Engine.Inspection.SymbolName name) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadBytes(CheatEngine.SDK.Engine.Memory.HostAddress address, System.Span destination, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryReadUtf8(CheatEngine.SDK.Engine.Memory.HostAddress address, int maximumLength, System.Span destination, bool wideCharacter, out int written, out int requiredLength, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.HostMemory.TryWriteBytes(CheatEngine.SDK.Engine.Memory.HostAddress address, System.ReadOnlySpan value, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadBytes(CheatEngine.SDK.Engine.Values.Address address, System.Span destination, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadPointer(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Runtime.PointerSize pointerSize, out CheatEngine.SDK.Engine.Values.Address value, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryReadUtf8(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, System.Span destination, bool wideCharacter, out int written, out int requiredLength, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWriteBytes(CheatEngine.SDK.Engine.Values.Address address, System.ReadOnlySpan value, out int written, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Memory.TargetMemory.TryWritePointer(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.SDK.Engine.Values.Address value, CheatEngine.SDK.Engine.Runtime.PointerSize pointerSize, out CheatEngine.SDK.Engine.Memory.MemoryAccessFailure failure) -> bool +static CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.operator !=(CheatEngine.SDK.Engine.Processes.CurrentProcessObservation left, CheatEngine.SDK.Engine.Processes.CurrentProcessObservation right) -> bool +static CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.operator ==(CheatEngine.SDK.Engine.Processes.CurrentProcessObservation left, CheatEngine.SDK.Engine.Processes.CurrentProcessObservation right) -> bool +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.GlobalUnavailable.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.InvalidResult.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.ProtectedLuaFailure(CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.SelectionNotConfirmed.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.Success.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.TargetNotAttached.get -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.operator !=(CheatEngine.SDK.Engine.Processes.ProcessOperationStatus left, CheatEngine.SDK.Engine.Processes.ProcessOperationStatus right) -> bool +static CheatEngine.SDK.Engine.Processes.ProcessOperationStatus.operator ==(CheatEngine.SDK.Engine.Processes.ProcessOperationStatus left, CheatEngine.SDK.Engine.Processes.ProcessOperationStatus right) -> bool +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetCheatEngineVersion(out double version) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetSystemArchitecture(out CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture architecture) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeHostOperations.TryGetTargetAbi(out CheatEngine.SDK.Engine.Runtime.TargetAbi abi) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations.ObserveCurrent(out CheatEngine.SDK.Engine.Processes.CurrentProcessObservation observation) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Processes.RuntimeProcessOperations.SelectAndObserve(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, out CheatEngine.SDK.Engine.Processes.CurrentProcessObservation observation) -> CheatEngine.SDK.Engine.Processes.ProcessOperationStatus +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.CurrentProcess.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId.ProcessSelection.get -> CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.GlobalUnavailable.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.InvalidResult.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.Matches(int resultCount) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.NoMatches.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.NoResult.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.ProtectedLuaFailure(CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.ResultListCountUnavailable.get -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.operator !=(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome.operator ==(CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome left, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanDetailed(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, out CheatEngine.SDK.Engine.Objects.Owned? results) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanDetailed(string! pattern, out CheatEngine.SDK.Engine.Objects.Owned? results) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanOutcome(string! pattern, CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions options, out CheatEngine.SDK.Engine.Objects.Owned? results) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Aob.AobScanner.TryScanOutcome(string! pattern, out CheatEngine.SDK.Engine.Objects.Owned? results) -> CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcome +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.operator !=(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.operator ==(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.operator !=(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.operator ==(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.operator !=(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.operator ==(CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult left, CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult right) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions.TryCreate(out CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession? session) -> bool +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions.TryCreateDetailed(out CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession? session) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus +static CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSessions.TryCreateWithOutcome(out CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession? session) -> CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome +static CheatEngine.SDK.Engine.Tables.CheatTableFiles.TryLoad(string! path, bool merge) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Tables.CheatTableFiles.TrySave(string! path) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.operator !=(CheatEngine.SDK.Engine.Targets.TargetIdentityCheck left, CheatEngine.SDK.Engine.Targets.TargetIdentityCheck right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.operator ==(CheatEngine.SDK.Engine.Targets.TargetIdentityCheck left, CheatEngine.SDK.Engine.Targets.TargetIdentityCheck right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.operator !=(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation left, CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation.operator ==(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation left, CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.operator !=(CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome left, CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.operator ==(CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome left, CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetSelection.ObserveCurrent() -> CheatEngine.SDK.Engine.Targets.TargetSelectionObservation +static CheatEngine.SDK.Engine.Targets.TargetSelection.ValidateCurrent(CheatEngine.SDK.Engine.Targets.TargetProcessIncarnation expected) -> CheatEngine.SDK.Engine.Targets.TargetIdentityCheck +static CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.operator !=(CheatEngine.SDK.Engine.Targets.TargetSelectionObservation left, CheatEngine.SDK.Engine.Targets.TargetSelectionObservation right) -> bool +static CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.operator ==(CheatEngine.SDK.Engine.Targets.TargetSelectionObservation left, CheatEngine.SDK.Engine.Targets.TargetSelectionObservation right) -> bool +~override CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Allocation.TargetMemoryAllocationOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.InstructionDisassembly.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.InstructionProfile.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.InstructionProfile.ToString() -> string +~override CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Assembly.InstructionTargetProfile.ToString() -> string +~override CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Inspection.SymbolRegistrationOptions.ToString() -> string +~override CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Processes.CurrentProcessObservation.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanReleaseOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Scanning.Values.MemoryScanResult.ToString() -> string +~override CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Targets.TargetIdentityCheck.ToString() -> string +~override CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Targets.TargetReleaseOutcome.ToString() -> string +~override CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.Equals(object obj) -> bool +~override CheatEngine.SDK.Engine.Targets.TargetSelectionObservation.ToString() -> string diff --git a/libs/CheatEngine.SDK.Hosting/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Hosting/PublicAPI.Shipped.txt new file mode 100644 index 00000000..2c0ef4f4 --- /dev/null +++ b/libs/CheatEngine.SDK.Hosting/PublicAPI.Shipped.txt @@ -0,0 +1,56 @@ +#nullable enable +CheatEngine.SDK.Hosting.Bootstrap.PluginHost +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Disabling = 4 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Enabled = 3 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Enabling = 2 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Registered = 1 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase.Uninitialized = 0 -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +CheatEngine.SDK.Hosting.Context.PluginContext +CheatEngine.SDK.Hosting.Context.PluginContext.Epoch.get -> int +CheatEngine.SDK.Hosting.Context.PluginContext.HasCheckSynchronize.get -> bool +CheatEngine.SDK.Hosting.Context.PluginContext.HasProcessMessages.get -> bool +CheatEngine.SDK.Hosting.Context.PluginContext.IsCurrent.get -> bool +CheatEngine.SDK.Hosting.Context.PluginContext.IsMainThread.get -> bool +CheatEngine.SDK.Hosting.Context.PluginContext.MainThreadId.get -> int +CheatEngine.SDK.Hosting.Context.PluginContext.PluginId.get -> uint +CheatEngine.SDK.Hosting.Context.PluginContext.ReportedExportsSize.get -> int +CheatEngine.SDK.Hosting.Context.PluginContext.ShutdownToken.get -> System.Threading.CancellationToken +CheatEngine.SDK.Hosting.Diagnostics.DebugOutputLogSink +CheatEngine.SDK.Hosting.Diagnostics.DebugOutputLogSink.Write(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel level, string! message, System.Exception? exception) -> void +CheatEngine.SDK.Hosting.Diagnostics.HostLog +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Error = 3 -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Information = 1 -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Trace = 0 -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel.Warning = 2 -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +CheatEngine.SDK.Hosting.Diagnostics.IHostLogSink +CheatEngine.SDK.Hosting.Diagnostics.IHostLogSink.Write(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel level, string! message, System.Exception? exception) -> void +CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin +CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.CheatEnginePlugin() -> void +CheatEngine.SDK.Hosting.Plugin.IPluginFactory +CheatEngine.SDK.Hosting.Plugin.IPluginFactory.Create() -> CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin! +CheatEngine.SDK.Hosting.Plugin.IPluginFactory.Utf8Name.get -> System.ReadOnlySpan +CheatEngine.SDK.Hosting.Threading.MainThread +abstract CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.OnDisable() -> void +abstract CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.OnEnable() -> void +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.Context.get -> CheatEngine.SDK.Hosting.Context.PluginContext? +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.InitializeManaged(nint initRecord, int hostArgument) -> int +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.IsEnabled.get -> bool +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.IsInitialized.get -> bool +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.LastInitRecordArgument.get -> int +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.LastVersionRecordSize.get -> int +static CheatEngine.SDK.Hosting.Bootstrap.PluginHost.Phase.get -> CheatEngine.SDK.Hosting.Bootstrap.PluginHostLifecyclePhase +static CheatEngine.SDK.Hosting.Diagnostics.DebugOutputLogSink.Instance.get -> CheatEngine.SDK.Hosting.Diagnostics.DebugOutputLogSink! +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.IsEnabled(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel level) -> bool +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.MinimumLevel.get -> CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.MinimumLevel.set -> void +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.Sink.get -> CheatEngine.SDK.Hosting.Diagnostics.IHostLogSink! +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.Sink.set -> void +static CheatEngine.SDK.Hosting.Diagnostics.HostLog.Write(CheatEngine.SDK.Hosting.Diagnostics.HostLogLevel level, string? message, System.Exception? exception = null) -> void +static CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin.Context.get -> CheatEngine.SDK.Hosting.Context.PluginContext! +static CheatEngine.SDK.Hosting.Threading.MainThread.CheckSynchronize(int timeoutMilliseconds) -> bool +static CheatEngine.SDK.Hosting.Threading.MainThread.Invoke(System.Func! function, TState state) -> TResult +static CheatEngine.SDK.Hosting.Threading.MainThread.Invoke(System.Action! action, TState state) -> void +static CheatEngine.SDK.Hosting.Threading.MainThread.IsMainThread.get -> bool +static CheatEngine.SDK.Hosting.Threading.MainThread.ProcessMessages() -> void diff --git a/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/libs/CheatEngine.SDK.Hosting/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Shipped.txt new file mode 100644 index 00000000..2010bb9b --- /dev/null +++ b/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Shipped.txt @@ -0,0 +1,259 @@ +#nullable enable +CheatEngine.SDK.Lua.Interop.Api.LuaApi +CheatEngine.SDK.Lua.Interop.Loading.LuaModule +CheatEngine.SDK.Lua.Interop.Types.luaL_Reg +CheatEngine.SDK.Lua.Interop.Types.luaL_Reg.func -> delegate* unmanaged[Cdecl] +CheatEngine.SDK.Lua.Interop.Types.luaL_Reg.luaL_Reg() -> void +CheatEngine.SDK.Lua.Interop.Types.luaL_Reg.name -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.currentline -> int +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.event -> int +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.i_ci -> void* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.istailcall -> sbyte +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.isvararg -> sbyte +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.lastlinedefined -> int +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.linedefined -> int +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.lua_Debug() -> void +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.name -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.namewhat -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.nparams -> byte +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.nups -> byte +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.short_src -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.source -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_Debug.what -> byte* +CheatEngine.SDK.Lua.Interop.Types.lua_State +CheatEngine.SDK.Lua.Interop.Types.lua_State.lua_State() -> void +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUAI_FIRSTPSEUDOIDX = -1001000 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUAI_MAXSTACK = 1000000 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUAL_NUMSIZES = 136 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRERR = 6 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRFILE = 7 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRGCMM = 5 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRMEM = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRRUN = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_ERRSYNTAX = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCCOLLECT = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCCOUNT = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCCOUNTB = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCISRUNNING = 9 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCRESTART = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCSETPAUSE = 6 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCSETSTEPMUL = 7 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCSTEP = 5 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_GCSTOP = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKCALL = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKCOUNT = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKLINE = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKRET = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_HOOKTAILCALL = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_IDSIZE = 60 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MASKCALL = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MASKCOUNT = 8 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MASKLINE = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MASKRET = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MAXINTEGER = 9223372036854775807 -> long +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MININTEGER = -9223372036854775808 -> long +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MINSTACK = 20 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MULTRET = -1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_NOREF = -2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_NUMTAGS = 9 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OK = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPADD = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPBAND = 7 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPBNOT = 13 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPBOR = 8 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPBXOR = 9 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPDIV = 5 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPEQ = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPIDIV = 6 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPLE = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPLT = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPMOD = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPMUL = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPPOW = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPSHL = 10 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPSHR = 11 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPSUB = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OPUNM = 12 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_REFNIL = -1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_REGISTRYINDEX = -1001000 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_RIDX_GLOBALS = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_RIDX_LAST = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_RIDX_MAINTHREAD = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TBOOLEAN = 1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TFUNCTION = 6 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TLIGHTUSERDATA = 2 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TNIL = 0 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TNONE = -1 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TNUMBER = 3 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TSTRING = 4 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TTABLE = 5 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TTHREAD = 8 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TUSERDATA = 7 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_VERSION_NUM = 503 -> int +const CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_YIELD = 1 -> int +const CheatEngine.SDK.Lua.Interop.Loading.LuaModule.CheatEngine64ModuleName = "lua53-64.dll" -> string! +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.GetMissingExports(nint moduleHandle) -> System.Collections.Generic.IReadOnlyList! +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.Initialize(nint moduleHandle) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.IsInitialized.get -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_COLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_DBLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_EXTRASPACE.get -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_IOLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_LOADLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_MATHLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_OSLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_SIGNATURE.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_STRLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_TABLIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.LUA_UTF8LIBNAME.get -> System.ReadOnlySpan +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.ModuleHandle.get -> nint +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.TryInitialize(nint moduleHandle, out string? failure) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_callmeta(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int obj, byte* e) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_dofile(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* fn) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_dostring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_getmetafield(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int obj, byte* e) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_getmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_getsubtable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, byte* fname) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_len(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> long +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadbuffer(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s, nuint sz, byte* n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadbufferx(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* buff, nuint sz, byte* name, byte* mode) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadfile(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* f) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadfilex(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* filename, byte* mode) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_loadstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_newmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* tname) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_newstate() -> CheatEngine.SDK.Lua.Interop.Types.lua_State* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_openlibs(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_ref(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int t) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_requiref(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* modname, delegate* unmanaged[Cdecl] openf, int glb) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_setfuncs(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.luaL_Reg* l, int nup) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_setmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* tname) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_testudata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int ud, byte* tname) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_tolstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, nuint* len) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_traceback(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.lua_State* L1, byte* msg, int level) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_typename(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int i) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaL_unref(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int t, int ref) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_absindex(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_arith(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int op) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_atpanic(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] panicf) -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_call(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n, int r) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_callk(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int nargs, int nresults, nint ctx, delegate* unmanaged[Cdecl] k) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_checkstack(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_close(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_compare(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx1, int idx2, int op) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_concat(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_copy(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int fromidx, int toidx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_createtable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int narr, int nrec) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_dump(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] writer, void* data, int strip) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_error(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gc(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int what, int data) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getallocf(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, void** ud) -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getextraspace(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getfield(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, byte* k) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getglobal(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* name) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gethook(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gethookcount(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gethookmask(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_geti(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, long n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getinfo(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* what, CheatEngine.SDK.Lua.Interop.Types.lua_Debug* ar) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getlocal(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.lua_Debug* ar, int n) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int objindex) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getstack(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int level, CheatEngine.SDK.Lua.Interop.Types.lua_Debug* ar) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gettable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_gettop(CheatEngine.SDK.Lua.Interop.Types.lua_State* l) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getupvalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int funcindex, int n) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_getuservalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_insert(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isboolean(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_iscfunction(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isfunction(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isinteger(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_islightuserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isnil(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isnone(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isnoneornil(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isnumber(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_istable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isthread(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> bool +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isuserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_isyieldable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_len(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_load(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] reader, void* dt, byte* chunkname, byte* mode) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_newstate(delegate* unmanaged[Cdecl] f, void* ud) -> CheatEngine.SDK.Lua.Interop.Types.lua_State* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_newtable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_newthread(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> CheatEngine.SDK.Lua.Interop.Types.lua_State* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_newuserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, nuint sz) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_next(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pcall(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n, int r, int f) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pcallk(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int nargs, int nresults, int errfunc, nint ctx, delegate* unmanaged[Cdecl] k) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pop(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushboolean(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int b) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushcclosure(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] fn, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushcfunction(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] f) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushglobaltable(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushinteger(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, long n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushlightuserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, void* p) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushliteral(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, System.ReadOnlySpan s) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushlstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s, nuint len) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushnil(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushnumber(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, double n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushthread(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_pushvalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawequal(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx1, int idx2) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawget(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawgeti(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, long n) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawgetp(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, void* p) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawlen(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> nuint +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawset(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawseti(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, long n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rawsetp(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, void* p) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_register(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* n, delegate* unmanaged[Cdecl] f) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_remove(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_replace(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_resume(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.lua_State* from, int narg) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_rotate(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setallocf(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] f, void* ud) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setfield(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, byte* k) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setglobal(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, byte* name) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_sethook(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, delegate* unmanaged[Cdecl] func, int mask, int count) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_seti(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx, long n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setlocal(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, CheatEngine.SDK.Lua.Interop.Types.lua_Debug* ar, int n) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setmetatable(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int objindex) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_settable(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_settop(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setupvalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int funcindex, int n) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_setuservalue(CheatEngine.SDK.Lua.Interop.Types.lua_State* l, int idx) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_status(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_stringtonumber(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, byte* s) -> nuint +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_toboolean(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tocfunction(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tointeger(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int i) -> long +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tointegerx(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, int* isnum) -> long +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tolstring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, nuint* len) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tonumber(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int i) -> double +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tonumberx(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx, int* isnum) -> double +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_topointer(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tostring(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int i) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_tothread(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> CheatEngine.SDK.Lua.Interop.Types.lua_State* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_touserdata(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_type(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int idx) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_typename(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int tp) -> byte* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_upvalueid(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int fidx, int n) -> void* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_upvalueindex(int i) -> int +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_upvaluejoin(CheatEngine.SDK.Lua.Interop.Types.lua_State* L, int fidx1, int n1, int fidx2, int n2) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_version(CheatEngine.SDK.Lua.Interop.Types.lua_State* L) -> double* +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.lua_xmove(CheatEngine.SDK.Lua.Interop.Types.lua_State* from, CheatEngine.SDK.Lua.Interop.Types.lua_State* to, int n) -> void +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_base.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_coroutine.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_debug.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_io.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_math.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_os.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_package.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_string.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_table.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Api.LuaApi.luaopen_utf8.get -> delegate* unmanaged[Cdecl] +static CheatEngine.SDK.Lua.Interop.Loading.LuaModule.TryGetLoaded(out nint moduleHandle) -> bool +static CheatEngine.SDK.Lua.Interop.Loading.LuaModule.TryGetLoaded(string! moduleName, out nint moduleHandle) -> bool diff --git a/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua.Interop/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/libs/CheatEngine.SDK.Lua/PublicAPI.Shipped.txt b/libs/CheatEngine.SDK.Lua/PublicAPI.Shipped.txt new file mode 100644 index 00000000..591309ec --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/PublicAPI.Shipped.txt @@ -0,0 +1,276 @@ +#nullable enable +CheatEngine.SDK.Lua.Callbacks.LuaCallback +CheatEngine.SDK.Lua.Callbacks.LuaCallback.Dispose() -> void +CheatEngine.SDK.Lua.Callbacks.LuaCallback.IsCurrent.get -> bool +CheatEngine.SDK.Lua.Callbacks.LuaCallback.IsReleased.get -> bool +CheatEngine.SDK.Lua.Callbacks.LuaCallback.Release(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Lua.Callbacks.LuaCallback.StateObject.get -> object? +CheatEngine.SDK.Lua.Callbacks.LuaCallback.TryPush(CheatEngine.SDK.Lua.State.LuaState state) -> bool +CheatEngine.SDK.Lua.Callbacks.LuaCallback.TryRegister(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan globalName) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Callbacks.LuaCallback +CheatEngine.SDK.Lua.Callbacks.LuaCallback.State.get -> TState? +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.Address.get -> nint +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.Equals(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction other) -> bool +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.IsNull.get -> bool +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.LuaNativeFunction() -> void +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.LuaNativeFunction(delegate* unmanaged[Cdecl] function) -> void +CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.LuaNativeFunction(nint address) -> void +CheatEngine.SDK.Lua.Callbacks.LuaThunk +CheatEngine.SDK.Lua.Calls.LuaComparison +CheatEngine.SDK.Lua.Calls.LuaComparison.Equal = 0 -> CheatEngine.SDK.Lua.Calls.LuaComparison +CheatEngine.SDK.Lua.Calls.LuaComparison.Less = 1 -> CheatEngine.SDK.Lua.Calls.LuaComparison +CheatEngine.SDK.Lua.Calls.LuaComparison.LessOrEqual = 2 -> CheatEngine.SDK.Lua.Calls.LuaComparison +CheatEngine.SDK.Lua.Calls.LuaError +CheatEngine.SDK.Lua.Calls.LuaError.Equals(CheatEngine.SDK.Lua.Calls.LuaError other) -> bool +CheatEngine.SDK.Lua.Calls.LuaError.LuaError() -> void +CheatEngine.SDK.Lua.Calls.LuaError.LuaError(CheatEngine.SDK.Lua.Calls.LuaStatus status, string! message) -> void +CheatEngine.SDK.Lua.Calls.LuaError.Message.get -> string! +CheatEngine.SDK.Lua.Calls.LuaError.Status.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Calls.LuaException +CheatEngine.SDK.Lua.Calls.LuaException.LuaException(CheatEngine.SDK.Lua.Calls.LuaError error) -> void +CheatEngine.SDK.Lua.Calls.LuaException.LuaException(string! message) -> void +CheatEngine.SDK.Lua.Calls.LuaException.LuaException(string! message, System.Exception! innerException) -> void +CheatEngine.SDK.Lua.Calls.LuaException.Status.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Calls.LuaStatus.Code.get -> int +CheatEngine.SDK.Lua.Calls.LuaStatus.Equals(CheatEngine.SDK.Lua.Calls.LuaStatus other) -> bool +CheatEngine.SDK.Lua.Calls.LuaStatus.IsOk.get -> bool +CheatEngine.SDK.Lua.Calls.LuaStatus.LuaStatus() -> void +CheatEngine.SDK.Lua.Calls.LuaStatus.LuaStatus(int code) -> void +CheatEngine.SDK.Lua.Calls.LuaStatus.ThrowIfFailed(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions +CheatEngine.SDK.Lua.Marshalling.AddressMarshaller +CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.AddressMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller +CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.BooleanMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller +CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller.DoubleMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller +CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, T value) -> void +CheatEngine.SDK.Lua.Marshalling.ILuaMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out T value) -> bool +CheatEngine.SDK.Lua.Marshalling.Int32Marshaller +CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.Int32Marshaller() -> void +CheatEngine.SDK.Lua.Marshalling.Int64Marshaller +CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.Int64Marshaller() -> void +CheatEngine.SDK.Lua.Marshalling.SingleMarshaller +CheatEngine.SDK.Lua.Marshalling.SingleMarshaller.SingleMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.StringMarshaller +CheatEngine.SDK.Lua.Marshalling.StringMarshaller.StringMarshaller() -> void +CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller +CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.Utf8Marshaller() -> void +CheatEngine.SDK.Lua.References.LuaRef +CheatEngine.SDK.Lua.References.LuaRef.Dispose() -> void +CheatEngine.SDK.Lua.References.LuaRef.Epoch.get -> int +CheatEngine.SDK.Lua.References.LuaRef.Identity.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Lua.References.LuaRef.IsCurrent.get -> bool +CheatEngine.SDK.Lua.References.LuaRef.IsResolved.get -> bool +CheatEngine.SDK.Lua.References.LuaRef.LuaRef() -> void +CheatEngine.SDK.Lua.References.LuaRef.Reference.get -> int +CheatEngine.SDK.Lua.References.LuaRef.Release(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Lua.References.LuaRef.StateGeneration.get -> int +CheatEngine.SDK.Lua.Runtime.LuaHostBinding +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.Equals(CheatEngine.SDK.Lua.Runtime.LuaHostBinding other) -> bool +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.HostObjectPusher.get -> nint +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.IsValid.get -> bool +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.LuaHostBinding() -> void +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.LuaHostBinding(delegate* unmanaged[Stdcall] stateProvider, delegate* unmanaged[Stdcall] hostObjectPusher, int mainThreadId) -> void +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.LuaHostBinding(nint stateProvider, nint hostObjectPusher, int mainThreadId) -> void +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.MainThreadId.get -> int +CheatEngine.SDK.Lua.Runtime.LuaHostBinding.StateProvider.get -> nint +CheatEngine.SDK.Lua.Runtime.LuaRuntime +CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation +CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation.Dispose() -> void +CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation.LuaRuntimeOperation() -> void +CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation.State.get -> CheatEngine.SDK.Lua.State.LuaState +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.AttachEpoch.get -> int +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.Equals(CheatEngine.SDK.Lua.Runtime.LuaStateIdentity other) -> bool +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.LuaStateIdentity() -> void +CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.StateGeneration.get -> int +CheatEngine.SDK.Lua.State.LuaFrame +CheatEngine.SDK.Lua.State.LuaFrame.AssertBalanced() -> void +CheatEngine.SDK.Lua.State.LuaFrame.Count.get -> int +CheatEngine.SDK.Lua.State.LuaFrame.Dispose() -> void +CheatEngine.SDK.Lua.State.LuaFrame.LuaFrame() -> void +CheatEngine.SDK.Lua.State.LuaFrame.LuaFrame(CheatEngine.SDK.Lua.State.LuaState state) -> void +CheatEngine.SDK.Lua.State.LuaFrame.State.get -> CheatEngine.SDK.Lua.State.LuaState +CheatEngine.SDK.Lua.State.LuaFrame.Top.get -> int +CheatEngine.SDK.Lua.State.LuaState +CheatEngine.SDK.Lua.State.LuaState.AbsoluteIndex(int index) -> int +CheatEngine.SDK.Lua.State.LuaState.Copy(int fromIndex, int toIndex) -> void +CheatEngine.SDK.Lua.State.LuaState.CreateRef() -> CheatEngine.SDK.Lua.References.LuaRef! +CheatEngine.SDK.Lua.State.LuaState.CreateTable(int arraySlots = 0, int recordSlots = 0) -> void +CheatEngine.SDK.Lua.State.LuaState.Equals(CheatEngine.SDK.Lua.State.LuaState other) -> bool +CheatEngine.SDK.Lua.State.LuaState.Handle.get -> nint +CheatEngine.SDK.Lua.State.LuaState.Insert(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.IsFunction(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsInteger(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsLightUserdata(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNil(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNone(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNoneOrNil(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNull.get -> bool +CheatEngine.SDK.Lua.State.LuaState.IsNumberConvertible(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsTable(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.IsUserdata(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.LuaState() -> void +CheatEngine.SDK.Lua.State.LuaState.LuaState(nint handle) -> void +CheatEngine.SDK.Lua.State.LuaState.NewUserdata(nuint size) -> nint +CheatEngine.SDK.Lua.State.LuaState.Pop(int count) -> void +CheatEngine.SDK.Lua.State.LuaState.PushBoolean(bool value) -> void +CheatEngine.SDK.Lua.State.LuaState.PushByteTable(System.ReadOnlySpan bytes) -> void +CheatEngine.SDK.Lua.State.LuaState.PushGlobalTable() -> void +CheatEngine.SDK.Lua.State.LuaState.PushInteger(long value) -> void +CheatEngine.SDK.Lua.State.LuaState.PushLightUserdata(nint address) -> void +CheatEngine.SDK.Lua.State.LuaState.PushNil() -> void +CheatEngine.SDK.Lua.State.LuaState.PushNumber(double value) -> void +CheatEngine.SDK.Lua.State.LuaState.PushString(System.ReadOnlySpan utf8) -> void +CheatEngine.SDK.Lua.State.LuaState.PushString(System.ReadOnlySpan text) -> void +CheatEngine.SDK.Lua.State.LuaState.PushUncheckedFunction(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk) -> void +CheatEngine.SDK.Lua.State.LuaState.PushValue(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.RawEquals(int index1, int index2) -> bool +CheatEngine.SDK.Lua.State.LuaState.RawGet(int tableIndex) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaState.RawGetIndex(int tableIndex, long key) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaState.RawGetPointer(int tableIndex, nint key) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaState.RawLength(int index) -> nuint +CheatEngine.SDK.Lua.State.LuaState.RawSetIndex(int tableIndex, long key) -> void +CheatEngine.SDK.Lua.State.LuaState.RawSetPointer(int tableIndex, nint key) -> void +CheatEngine.SDK.Lua.State.LuaState.Remove(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.Replace(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.Rotate(int index, int count) -> void +CheatEngine.SDK.Lua.State.LuaState.SetMetatable(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.SetTop(int index) -> void +CheatEngine.SDK.Lua.State.LuaState.ToBoolean(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.ToPointer(int index) -> nint +CheatEngine.SDK.Lua.State.LuaState.ToUserdata(int index) -> nint +CheatEngine.SDK.Lua.State.LuaState.Top.get -> int +CheatEngine.SDK.Lua.State.LuaState.TryCall(int argumentCount, int resultCount) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryCall(int argumentCount, int resultCount, int messageHandlerIndex) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryCompare(int index1, int index2, CheatEngine.SDK.Lua.Calls.LuaComparison comparison, out bool result) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryCopyUtf8(int index, System.Span destination, out int written) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryEnsureStack(int extraSlots) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryExecute(System.ReadOnlySpan source, int resultCount, System.ReadOnlySpan chunkName = default(System.ReadOnlySpan)) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryGetField(int index, System.ReadOnlySpan key) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryGetGlobal(System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryGetIndex(int index, long key) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryGetMetatable(int index) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryGetTable(int index) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryLength(int index) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryLoad(System.ReadOnlySpan source, System.ReadOnlySpan chunkName = default(System.ReadOnlySpan)) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryNext(int index, out bool hasNext) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryPushFunction(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryPushRef(CheatEngine.SDK.Lua.References.LuaRef! reference) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryPushString(System.ReadOnlySpan utf8) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryRawSet(int tableIndex) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryReadInteger(int index, out long value) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryReadNumber(int index, out double value) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryReadString(int index, out string? value) -> bool +CheatEngine.SDK.Lua.State.LuaState.TryReadUtf8(int index, out System.ReadOnlySpan utf8) -> bool +CheatEngine.SDK.Lua.State.LuaState.TrySetField(int index, System.ReadOnlySpan key) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TrySetGlobal(System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TrySetIndex(int index, long key) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TrySetTable(int index) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TryToString(int index) -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.State.LuaState.TypeName(CheatEngine.SDK.Lua.State.LuaType type) -> System.ReadOnlySpan +CheatEngine.SDK.Lua.State.LuaState.TypeName(int index) -> System.ReadOnlySpan +CheatEngine.SDK.Lua.State.LuaState.TypeOf(int index) -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Boolean = 1 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Function = 6 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.LightUserdata = 2 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Nil = 0 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.None = -1 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Number = 3 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.String = 4 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Table = 5 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Thread = 8 -> CheatEngine.SDK.Lua.State.LuaType +CheatEngine.SDK.Lua.State.LuaType.Userdata = 7 -> CheatEngine.SDK.Lua.State.LuaType +const CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailureResultCount = 2 -> int +const CheatEngine.SDK.Lua.State.LuaState.MinimumFreeSlots = 20 -> int +const CheatEngine.SDK.Lua.State.LuaState.MultipleResults = -1 -> int +const CheatEngine.SDK.Lua.State.LuaState.RegistryIndex = -1001000 -> int +override CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.GetHashCode() -> int +override CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.ToString() -> string! +override CheatEngine.SDK.Lua.Calls.LuaError.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Calls.LuaError.GetHashCode() -> int +override CheatEngine.SDK.Lua.Calls.LuaError.ToString() -> string! +override CheatEngine.SDK.Lua.Calls.LuaStatus.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Calls.LuaStatus.GetHashCode() -> int +override CheatEngine.SDK.Lua.Calls.LuaStatus.ToString() -> string! +override CheatEngine.SDK.Lua.References.LuaRef.ToString() -> string! +override CheatEngine.SDK.Lua.Runtime.LuaHostBinding.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Runtime.LuaHostBinding.GetHashCode() -> int +override CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.GetHashCode() -> int +override CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.ToString() -> string! +override CheatEngine.SDK.Lua.State.LuaState.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.State.LuaState.GetHashCode() -> int +override CheatEngine.SDK.Lua.State.LuaState.ToString() -> string! +static CheatEngine.SDK.Lua.Callbacks.LuaCallback.TryCreate(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk, TState! stateObject, out CheatEngine.SDK.Lua.Callbacks.LuaCallback? callback) -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.operator !=(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction left, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction right) -> bool +static CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction.operator ==(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction left, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction right) -> bool +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(CheatEngine.SDK.Lua.State.LuaState state, System.Exception? exception) -> int +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan message) -> int +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.Fail(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan message) -> int +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.FailBadArgument(CheatEngine.SDK.Lua.State.LuaState state, int argument, System.ReadOnlySpan expected) -> int +static CheatEngine.SDK.Lua.Callbacks.LuaThunk.TryGetState(CheatEngine.SDK.Lua.State.LuaState state, out TState? value) -> bool +static CheatEngine.SDK.Lua.Calls.LuaError.FromStack(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Calls.LuaStatus status) -> CheatEngine.SDK.Lua.Calls.LuaError +static CheatEngine.SDK.Lua.Calls.LuaError.operator !=(CheatEngine.SDK.Lua.Calls.LuaError left, CheatEngine.SDK.Lua.Calls.LuaError right) -> bool +static CheatEngine.SDK.Lua.Calls.LuaError.operator ==(CheatEngine.SDK.Lua.Calls.LuaError left, CheatEngine.SDK.Lua.Calls.LuaError right) -> bool +static CheatEngine.SDK.Lua.Calls.LuaException.Throw(CheatEngine.SDK.Lua.Calls.LuaError error) -> void +static CheatEngine.SDK.Lua.Calls.LuaException.ThrowFromStack(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Calls.LuaStatus status) -> void +static CheatEngine.SDK.Lua.Calls.LuaStatus.FileError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.GcMetamethodError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.MemoryError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.MessageHandlerError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.Ok.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.RuntimeError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.SyntaxError.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.Yield.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +static CheatEngine.SDK.Lua.Calls.LuaStatus.operator !=(CheatEngine.SDK.Lua.Calls.LuaStatus left, CheatEngine.SDK.Lua.Calls.LuaStatus right) -> bool +static CheatEngine.SDK.Lua.Calls.LuaStatus.operator ==(CheatEngine.SDK.Lua.Calls.LuaStatus left, CheatEngine.SDK.Lua.Calls.LuaStatus right) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(CheatEngine.SDK.Lua.State.LuaState state, int top) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(CheatEngine.SDK.Lua.State.LuaState state, int top, out TResult result) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Throw(CheatEngine.SDK.Lua.State.LuaState state, int top, CheatEngine.SDK.Lua.Calls.LuaStatus status) -> void +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnexpectedResult(CheatEngine.SDK.Lua.State.LuaState state, int top, int index, string! globalName, string! expected) -> void +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.ThrowUnresolvedGlobal(CheatEngine.SDK.Lua.State.LuaState state, int top, string! globalName) -> void +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPush(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.References.LuaRef! cache, System.ReadOnlySpan name) -> bool +static CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, nuint value) -> void +static CheatEngine.SDK.Lua.Marshalling.AddressMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out nuint value) -> bool +static CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, bool value) -> void +static CheatEngine.SDK.Lua.Marshalling.BooleanMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out bool value) -> bool +static CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, double value) -> void +static CheatEngine.SDK.Lua.Marshalling.DoubleMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out double value) -> bool +static CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, int value) -> void +static CheatEngine.SDK.Lua.Marshalling.Int32Marshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out int value) -> bool +static CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, long value) -> void +static CheatEngine.SDK.Lua.Marshalling.Int64Marshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out long value) -> bool +static CheatEngine.SDK.Lua.Marshalling.SingleMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, float value) -> void +static CheatEngine.SDK.Lua.Marshalling.SingleMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out float value) -> bool +static CheatEngine.SDK.Lua.Marshalling.StringMarshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, string? value) -> void +static CheatEngine.SDK.Lua.Marshalling.StringMarshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out string! value) -> bool +static CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.Push(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan value) -> void +static CheatEngine.SDK.Lua.Marshalling.Utf8Marshaller.TryRead(CheatEngine.SDK.Lua.State.LuaState state, int index, out System.ReadOnlySpan value) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaHostBinding.operator !=(CheatEngine.SDK.Lua.Runtime.LuaHostBinding left, CheatEngine.SDK.Lua.Runtime.LuaHostBinding right) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaHostBinding.operator ==(CheatEngine.SDK.Lua.Runtime.LuaHostBinding left, CheatEngine.SDK.Lua.Runtime.LuaHostBinding right) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation() -> CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation(CheatEngine.SDK.Lua.State.LuaState state) -> CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState() -> CheatEngine.SDK.Lua.State.LuaState +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.Attach(in CheatEngine.SDK.Lua.Runtime.LuaHostBinding binding) -> void +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.CurrentBinding.get -> CheatEngine.SDK.Lua.Runtime.LuaHostBinding +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.CurrentStateIdentity.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.Detach() -> void +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.Epoch.get -> int +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.IsAttached.get -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.IsMainThread.get -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.PushHostObject(CheatEngine.SDK.Lua.State.LuaState state, nint nativeObject) -> void +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.StateGeneration.get -> int +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryAcquireOperation(out CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryAcquireState(out CheatEngine.SDK.Lua.State.LuaState state) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.operator !=(CheatEngine.SDK.Lua.Runtime.LuaStateIdentity left, CheatEngine.SDK.Lua.Runtime.LuaStateIdentity right) -> bool +static CheatEngine.SDK.Lua.Runtime.LuaStateIdentity.operator ==(CheatEngine.SDK.Lua.Runtime.LuaStateIdentity left, CheatEngine.SDK.Lua.Runtime.LuaStateIdentity right) -> bool +static CheatEngine.SDK.Lua.State.LuaState.operator !=(CheatEngine.SDK.Lua.State.LuaState left, CheatEngine.SDK.Lua.State.LuaState right) -> bool +static CheatEngine.SDK.Lua.State.LuaState.operator ==(CheatEngine.SDK.Lua.State.LuaState left, CheatEngine.SDK.Lua.State.LuaState right) -> bool diff --git a/libs/CheatEngine.SDK.Lua/PublicAPI.Unshipped.txt b/libs/CheatEngine.SDK.Lua/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..c342c3d6 --- /dev/null +++ b/libs/CheatEngine.SDK.Lua/PublicAPI.Unshipped.txt @@ -0,0 +1,128 @@ +#nullable enable +CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Equals(CheatEngine.SDK.Lua.Calls.LuaOperationStatus other) -> bool +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.IsSuccess.get -> bool +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Kind.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.LuaOperationStatus() -> void +CheatEngine.SDK.Lua.Calls.LuaOperationStatus.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.GlobalUnavailable = 1 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.InvalidResult = 4 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.LuaFailure = 2 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.NilResult = 3 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.StackUnavailable = 5 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind.Success = 0 -> CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.IsSuccess.get -> bool +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.LuaGlobalPushOutcome() -> void +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.Status.get -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.ToOperationStatus() -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus.LuaFailure = 2 -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus.Success = 0 -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus.Unavailable = 1 -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy +CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting = 0 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy +CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.ReplaceExisting = 1 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry.Function.get -> CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry.LuaRegistrationEntry() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry.LuaRegistrationEntry(string! name, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction function) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationEntry.Name.get -> string! +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Deconstruct(out string! Name, out CheatEngine.SDK.Lua.Calls.LuaStatus LuaStatus) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Equals(CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure other) -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.LuaRegistrationFailure() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.LuaRegistrationFailure(string! Name, CheatEngine.SDK.Lua.Calls.LuaStatus LuaStatus) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.LuaStatus.init -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Name.get -> string! +CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Name.init -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.Dispose() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.Identity.get -> CheatEngine.SDK.Lua.Runtime.LuaStateIdentity +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.IsDisposed.get -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.LastReleaseOutcome.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.ReleaseWithOutcome() -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationLease.ReleaseWithOutcome(CheatEngine.SDK.Lua.State.LuaState state) -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Deconstruct(out string! Name, out CheatEngine.SDK.Lua.Calls.LuaStatus LuaStatus) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Equals(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure other) -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.LuaRegistrationReleaseFailure() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.LuaRegistrationReleaseFailure(string! Name, CheatEngine.SDK.Lua.Calls.LuaStatus LuaStatus) -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.LuaStatus.get -> CheatEngine.SDK.Lua.Calls.LuaStatus +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.LuaStatus.init -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Name.get -> string! +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Name.init -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.AlreadyReleased = 4 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.NotAttempted = 0 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.PartiallyReleased = 2 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Released = 1 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind.Stale = 3 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.Equals(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome other) -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.Failures.get -> System.Collections.Generic.IReadOnlyList! +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.IsComplete.get -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.Kind.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.LuaRegistrationReleaseOutcome() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.RemainingCount.get -> int +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.RemovedCount.get -> int +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.ReplacementCount.get -> int +CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.RestoredCount.get -> int +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Equals(CheatEngine.SDK.Lua.Registration.LuaRegistrationResult other) -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Failure.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure? +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.IsSuccess.get -> bool +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Kind.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Lease.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationLease? +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.LuaRegistrationResult() -> void +CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Rollback.get -> CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Collision = 2 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.PreflightFailed = 3 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.PublicationFailed = 4 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Succeeded = 1 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind.Unspecified = 0 -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind +CheatEngine.SDK.Lua.Registration.LuaRegistrationSet +CheatEngine.SDK.Lua.State.LuaState.TryPushGeneratedFunction(CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk, CheatEngine.SDK.Lua.Runtime.LuaStateIdentity identity, bool requiresAttachedRuntime) -> CheatEngine.SDK.Lua.Calls.LuaStatus +override CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Equals(object? obj) -> bool +override CheatEngine.SDK.Lua.Calls.LuaOperationStatus.GetHashCode() -> int +override CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.GetHashCode() -> int +override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.GetHashCode() -> int +override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.GetHashCode() -> int +override CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.GetHashCode() -> int +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.GlobalUnavailable.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.InvalidResult.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.LuaFailure(CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.NilResult.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.StackUnavailable.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.Success.get -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.operator !=(CheatEngine.SDK.Lua.Calls.LuaOperationStatus left, CheatEngine.SDK.Lua.Calls.LuaOperationStatus right) -> bool +static CheatEngine.SDK.Lua.Calls.LuaOperationStatus.operator ==(CheatEngine.SDK.Lua.Calls.LuaOperationStatus left, CheatEngine.SDK.Lua.Calls.LuaOperationStatus right) -> bool +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(CheatEngine.SDK.Lua.State.LuaState state, int top, CheatEngine.SDK.Lua.Calls.LuaOperationStatus status) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.CompilerServices.LuaCallSupport.Fail(CheatEngine.SDK.Lua.State.LuaState state, int top, CheatEngine.SDK.Lua.Calls.LuaOperationStatus status, out TResult result) -> CheatEngine.SDK.Lua.Calls.LuaOperationStatus +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPushWithOutcome(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.References.LuaRef! cache, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalFunctions.TryPushWithStatus(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.References.LuaRef! cache, System.ReadOnlySpan name) -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.LuaFailure(CheatEngine.SDK.Lua.Calls.LuaStatus luaStatus) -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.Success.get -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +static CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome.Unavailable.get -> CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushOutcome +static CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.operator !=(CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure left, CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.operator ==(CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure left, CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.operator !=(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure left, CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.operator ==(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure left, CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.operator !=(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome left, CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.operator ==(CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome left, CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.operator !=(CheatEngine.SDK.Lua.Registration.LuaRegistrationResult left, CheatEngine.SDK.Lua.Registration.LuaRegistrationResult right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.operator ==(CheatEngine.SDK.Lua.Registration.LuaRegistrationResult left, CheatEngine.SDK.Lua.Registration.LuaRegistrationResult right) -> bool +static CheatEngine.SDK.Lua.Registration.LuaRegistrationSet.Register(CheatEngine.SDK.Lua.State.LuaState state, System.ReadOnlySpan entries, CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy collisionPolicy = CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy.RejectExisting) -> CheatEngine.SDK.Lua.Registration.LuaRegistrationResult +static CheatEngine.SDK.Lua.Runtime.LuaRuntime.TryPushGeneratedFunction(CheatEngine.SDK.Lua.State.LuaState state, CheatEngine.SDK.Lua.Callbacks.LuaNativeFunction thunk) -> CheatEngine.SDK.Lua.Calls.LuaStatus +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.Equals(object obj) -> bool +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationFailure.ToString() -> string +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.Equals(object obj) -> bool +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseFailure.ToString() -> string +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.Equals(object obj) -> bool +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseOutcome.ToString() -> string +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.Equals(object obj) -> bool +~override CheatEngine.SDK.Lua.Registration.LuaRegistrationResult.ToString() -> string diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiFileTests.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiFileTests.cs new file mode 100644 index 00000000..72644269 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiFileTests.cs @@ -0,0 +1,127 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// Shape of the PublicAPI files of the six shipping libraries. The build enforces their content (RS0016/RS0017, and +/// CESDK9003 when a file is missing); these tests keep the files mergeable by ordinal sort and union and keep +/// PublicAPI.Shipped.txt equal to the published 1.0.0 surface plus explicit *REMOVED* declarations. +/// +public sealed class PublicApiFileTests +{ + private static readonly string[] s_publicApiFileNames = + [PublicApiLibrary.ShippedFileName, PublicApiLibrary.UnshippedFileName]; + + [Fact] + public void Every_shipping_library_has_both_public_api_files() + { + SortedSet libraries = new(PublicApiLibrary.EnumerateLibraryDirectories(), StringComparer.Ordinal); + SortedSet tracked = new(StringComparer.Ordinal); + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + tracked.Add(library.LibraryDirectory); + } + + Assert.Equal(libraries, tracked); + Assert.Equal(6, tracked.Count); + } + + [Fact] + public void Public_api_files_exist_only_next_to_shipping_libraries() + { + SortedSet libraries = new(PublicApiLibrary.EnumerateLibraryDirectories(), StringComparer.Ordinal); + List strays = []; + foreach (string pattern in s_publicApiFileNames) + { + foreach (string file in RepositoryRoot.EnumerateSourceFiles(pattern)) + { + if (!libraries.Contains(file[..file.LastIndexOf('/')])) + { + strays.Add(file); + } + } + } + + Assert.True(strays.Count == 0, $"PublicAPI files outside libs//: {string.Join(", ", strays)}"); + } + + [Fact] + public void Every_public_api_file_starts_with_nullable_enable() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + Assert.True(StartsWithHeader(library.ShippedFile), + $"{library.ShippedPath} must start with '{PublicApiLibrary.Header}'."); + Assert.True(StartsWithHeader(library.UnshippedFile), + $"{library.UnshippedPath} must start with '{PublicApiLibrary.Header}'."); + } + } + + [Fact] + public void Every_public_api_file_is_ordinally_sorted_after_its_header() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + AssertSortedAndDistinct(library.ShippedPath, library.ShippedFile); + AssertSortedAndDistinct(library.UnshippedPath, library.UnshippedFile); + } + } + + [Fact] + public void Shipped_files_never_contain_removed_markers() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + Assert.DoesNotContain(library.Shipped, + static line => line.StartsWith(PublicApiLibrary.RemovedPrefix, StringComparison.Ordinal)); + } + } + + [Fact] + public void Every_removed_line_names_a_line_of_the_shipped_file() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + HashSet shipped = new(library.Shipped, StringComparer.Ordinal); + foreach (string removed in library.Removed) + { + Assert.True(shipped.Contains(removed), + $"{library.UnshippedPath}: '*REMOVED*{removed}' does not repeat a line of {PublicApiLibrary.ShippedFileName} exactly."); + } + } + } + + [Fact] + public void Unshipped_never_redeclares_a_live_shipped_line() + { + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + HashSet shipped = new(library.Shipped, StringComparer.Ordinal); + HashSet removed = new(library.Removed, StringComparer.Ordinal); + foreach (string added in library.Added) + { + Assert.False(shipped.Contains(added) && !removed.Contains(added), + $"{library.UnshippedPath}: '{added}' is already shipped."); + } + } + } + + private static bool StartsWithHeader(IReadOnlyList lines) + { + return lines.Count > 0 && string.Equals(lines[0], PublicApiLibrary.Header, StringComparison.Ordinal); + } + + private static void AssertSortedAndDistinct(string path, IReadOnlyList lines) + { + for (int index = 1; index < lines.Count; index++) + { + Assert.False(string.IsNullOrWhiteSpace(lines[index]), $"{path}:{index + 1} is blank."); + if (index > 1) + { + int order = string.CompareOrdinal(lines[index - 1], lines[index]); + Assert.True(order < 0, + $"{path}:{index + 1} is {(order == 0 ? "a duplicate" : "out of ordinal order")}: '{lines[index]}'."); + } + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs new file mode 100644 index 00000000..841d630c --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs @@ -0,0 +1,135 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// The committed PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt pair of one shipping library, in the +/// Microsoft.CodeAnalysis.PublicApiAnalyzers format: a #nullable enable header, then one declaration per line; +/// Unshipped may prefix a line of Shipped with *REMOVED*. +/// +internal sealed class PublicApiLibrary +{ + public const string Header = "#nullable enable"; + public const string RemovedPrefix = "*REMOVED*"; + public const string ShippedFileName = "PublicAPI.Shipped.txt"; + public const string UnshippedFileName = "PublicAPI.Unshipped.txt"; + + private PublicApiLibrary(string libraryDirectory) + { + LibraryDirectory = libraryDirectory; + ShippedFile = ReadLines(ShippedPath); + UnshippedFile = ReadLines(UnshippedPath); + } + + /// Repository-relative directory of the library, for example libs/CheatEngine.SDK.Engine. + public string LibraryDirectory + { + get; + } + + /// The assembly name, which is also the directory name. + public string Name => LibraryDirectory[(LibraryDirectory.LastIndexOf('/') + 1)..]; + + public string ShippedPath => $"{LibraryDirectory}/{ShippedFileName}"; + + public string UnshippedPath => $"{LibraryDirectory}/{UnshippedFileName}"; + + /// Every line of PublicAPI.Shipped.txt, header included. + public IReadOnlyList ShippedFile + { + get; + } + + /// Every line of PublicAPI.Unshipped.txt, header included. + public IReadOnlyList UnshippedFile + { + get; + } + + /// The declarations of the published CheatEngine.SDK 1.0.0 surface (Shipped without its header). + public IEnumerable Shipped => WithoutHeader(ShippedFile); + + /// Declarations added since 1.0.0 (Unshipped lines without the removal marker). + public IEnumerable Added + { + get + { + foreach (string line in WithoutHeader(UnshippedFile)) + { + if (!line.StartsWith(RemovedPrefix, StringComparison.Ordinal)) + { + yield return line; + } + } + } + } + + /// Shipped declarations that no longer exist, with the *REMOVED* marker stripped. + public IEnumerable Removed + { + get + { + foreach (string line in WithoutHeader(UnshippedFile)) + { + if (line.StartsWith(RemovedPrefix, StringComparison.Ordinal)) + { + yield return line[RemovedPrefix.Length..]; + } + } + } + } + + /// Every repository-relative libs/<name> directory that holds a project file. + public static IReadOnlyList EnumerateLibraryDirectories() + { + SortedSet directories = new(StringComparer.Ordinal); + foreach (string project in RepositoryRoot.EnumerateSourceFiles("*.csproj")) + { + if (project.StartsWith("libs/", StringComparison.Ordinal)) + { + directories.Add(project[..project.LastIndexOf('/')]); + } + } + + return [.. directories]; + } + + /// Every shipping library that has both files, in ordinal order of directory. + public static IReadOnlyList LoadAll() + { + List libraries = []; + foreach (string directory in EnumerateLibraryDirectories()) + { + if (File.Exists(FullPath($"{directory}/{ShippedFileName}")) + && File.Exists(FullPath($"{directory}/{UnshippedFileName}"))) + { + libraries.Add(new PublicApiLibrary(directory)); + } + } + + return libraries; + } + + private static IEnumerable WithoutHeader(IReadOnlyList lines) + { + for (int index = 0; index < lines.Count; index++) + { + if (index == 0 && string.Equals(lines[index], Header, StringComparison.Ordinal)) + { + continue; + } + + yield return lines[index]; + } + } + + private static string[] ReadLines(string relativePath) + { + return File.ReadAllLines(FullPath(relativePath)); + } + + private static string FullPath(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } +} From fdb61312b56ffeea1f3e0ac9f14c3016fdde6a44 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:38:47 +0200 Subject: [PATCH 013/199] Validate the package against CheatEngine.SDK 1.0.0 on the 2.0 line main already breaks the published 1.0.0 API, and the maintainer decided that the next release is 2.0.0 with every break declared. Version line: MinVerMinimumMajorMinor goes from 1.0 to 2.0, so untagged commits pack as 2.0.0-alpha.0.N. MinVer derives AssemblyVersion from the major (2.0.0.0), and the GeneratedCode attribute in consumers' generated files changes accordingly. Baseline: src/CheatEngine.SDK sets PackageValidationBaselineVersion 1.0.0 next to EnablePackageValidation, so every pack compares lib/net10.0 with the published package (downloaded at restore as a PackageDownload): https://learn.microsoft.com/dotnet/fundamentals/apicompat/package-validation/baseline-version-validator CompatibilitySuppressions.xml was generated once with -p:ApiCompatGenerateSuppressionFile=true and reviewed: nine baseline suppressions, all Left == Right. CP0002 for the AddressResolutionOptions constructor, UseHostSymbolTable accessors and Deconstruct; CP0011 for MemoryAccessFailure.DestinationTooSmall, WriteFailed and InvalidResult; and, contrary to the expectation that ApiCompat has no rule for a field type change, CP0002 on the F: DocIds of AddressListPluginInit.Callback and DisassemblerContextPluginInit.Callback (typed function pointer to void*). No CP0003 (assembly version 1.0.0.0 to 2.0.0.0) is reported. Guards (Directory.Build.targets, before GenerateNuspec, packable only): - CESDK9006: package validation off, no baseline or strict mode; in a CI build (ContinuousIntegrationBuild), any of ApiCompatGenerateSuppression- File, GenerateCompatibilitySuppressionFile, ApiCompatPermitUnnecessary- Suppressions, DisablePackageBaselineValidation, RunApiCompat=false or a custom PackageValidationBaselinePath. Any CPxxxx or PKVxxx code in NoWarn (ApiCompat honors NoWarn) is refused as well. - CESDK9007 (after MinVer): a suppression file with baseline suppressions requires a package major above the baseline's, so the old -p:MinVerVersionOverride=1.0.0 rehearsal fails. Verified: a CI-like pack produces exactly one CheatEngine.SDK.2.0.0-alpha.0.51.nupkg with ApiCompat green; removing a CP0011 or CP0002 entry fails the pack; a stale entry fails it ("Unnecessary suppressions found"); MinVerVersionOverride=1.1.0 fails with CESDK9007; CI + ApiCompatGenerateSuppressionFile and strict mode fail with CESDK9006; a 2.0.0 override packs. eng/api/ records the rest of the contract: - apicompat-invisible-changes.txt: *REMOVED* lines ApiCompat cannot see (none today), plus the attribute-only changes invisible to both tools. - client-consumed-sdk-types.txt: the Client's SDK-type allowlist and the enums it translates, copied from Client 881c14c with provenance. - client-induced-breaks.txt: the suppressions touching those types (the AddressResolutionOptions and MemoryAccessFailure entries), for the Client's docs/migration/sdk-2.0.md. - README.md: public API tracking, the baseline and its regeneration command (integrator only), the Client flag, the enum policy, the 1.0.0 source-versus-package identities, the toolchain pin and the NuGet audit policy, and the guard table. Tests (Repository.Tests/PublicApi): - CompatibilitySuppressionTests (trait Qualification=Q48, SDK-side C0 evidence only): suppressions are baseline, same-assembly CP0001/CP0002/ CP0011 entries; every suppression names a *REMOVED* line and every *REMOVED* line is suppressed or declared invisible (matched by type and member name); the induced-break list is exactly derived; every consumed type resolves or is marked unresolved. - EnumContractTests: the nine CE-constant enums, CheatEngineArchitecture and TargetAbi keep their 1.0.0 members; the 23 enums added since 1.0.0 are classified; status/outcome enums start with a neutral zero member (Unknown; Unspecified, Uninitialized, NotAttempted tolerated) except eight pending ones owned by S-SCAN, S-RT, S-RES and S-GEN-A, a list the ratchet test only lets shrink. --- CheatEngine.SDK.slnx | 4 + Directory.Build.props | 9 +- Directory.Build.targets | 48 ++++ eng/api/README.md | 174 +++++++++++++ eng/api/apicompat-invisible-changes.txt | 17 ++ eng/api/client-consumed-sdk-types.txt | 44 ++++ eng/api/client-induced-breaks.txt | 18 ++ src/CheatEngine.SDK/CheatEngine.SDK.csproj | 7 + .../CompatibilitySuppressions.xml | 67 +++++ .../PublicApi/ApiContractFiles.cs | 98 +++++++ .../PublicApi/CompatibilitySuppression.cs | 13 + .../CompatibilitySuppressionTests.cs | 195 ++++++++++++++ .../PublicApi/EnumContractTests.cs | 239 ++++++++++++++++++ .../PublicApi/PublicApiDeclarations.cs | 194 ++++++++++++++ .../PublicApi/PublicApiLibrary.cs | 12 + .../README.md | 33 +++ 16 files changed, 1170 insertions(+), 2 deletions(-) create mode 100644 eng/api/README.md create mode 100644 eng/api/apicompat-invisible-changes.txt create mode 100644 eng/api/client-consumed-sdk-types.txt create mode 100644 eng/api/client-induced-breaks.txt create mode 100644 src/CheatEngine.SDK/CompatibilitySuppressions.xml create mode 100644 tests/CheatEngine.SDK.Repository.Tests/PublicApi/ApiContractFiles.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppression.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/PublicApi/EnumContractTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiDeclarations.cs diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 696e7dd5..b73f6b8f 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -27,6 +27,10 @@ + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 80a399eb..5e3f8740 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -67,8 +67,13 @@ false v - - 1.0 + + 2.0 diff --git a/Directory.Build.targets b/Directory.Build.targets index f0c812dd..c9a410c5 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -111,6 +111,54 @@ Text="NuGetAuditSuppress '%(NuGetAuditSuppress.Identity)' expired on %(NuGetAuditSuppress.Expires). Re-review the advisory: fix it, or renew the suppression with a new justification and date."/> + + + + <_CheatEngineSdkPackNoWarnCode Include="$([MSBuild]::Unescape($([System.Text.RegularExpressions.Regex]::Replace('$(NoWarn)', '[\s,;]+', ';').ToUpperInvariant())))"/> + + <_CheatEngineSdkSilencedCompatCode Include="@(_CheatEngineSdkPackNoWarnCode)" + Condition="$([System.String]::Copy('%(Identity)').StartsWith('CP')) or $([System.String]::Copy('%(Identity)').StartsWith('PKV'))"/> + + + + + + + + + + <_CheatEngineSdkSuppressionFile>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', 'CompatibilitySuppressions.xml')) + <_CheatEngineSdkDeclaresBreaks>false + <_CheatEngineSdkDeclaresBreaks Condition="Exists('$(_CheatEngineSdkSuppressionFile)') and $([System.IO.File]::ReadAllText('$(_CheatEngineSdkSuppressionFile)').Contains('<IsBaselineSuppression>true</IsBaselineSuppression>'))">true + <_CheatEngineSdkPackageMajor>$(PackageVersion.Split('-')[0].Split('.')[0]) + <_CheatEngineSdkBaselineMajor>$(PackageValidationBaselineVersion.Split('-')[0].Split('.')[0]) + + + + --diagnostics RS0016 --severity info`). `PublicAPI.Shipped.txt` changes only at a + release: the release applies the `*REMOVED*` lines to Shipped, moves the rest of Unshipped into it, and leaves + Unshipped with its `#nullable enable` header. Files stay ordinally sorted after the header, so parallel changes merge + by union and sort (`PublicApiFileTests`). + +## ApiCompat baseline + +`src/CheatEngine.SDK` sets `EnablePackageValidation` and `PackageValidationBaselineVersion` 1.0.0: every pack compares +`lib/net10.0` with the published 1.0.0 package +([baseline validator](https://learn.microsoft.com/dotnet/fundamentals/apicompat/package-validation/baseline-version-validator)). +Restore downloads the baseline as a `PackageDownload`, so a pack needs nuget.org once; the local escape hatch for an +offline pack is `-p:DisablePackageBaselineValidation=true`, which `CESDK9006` refuses in CI. + +Intentional breaks are listed in +[`src/CheatEngine.SDK/CompatibilitySuppressions.xml`](../../src/CheatEngine.SDK/CompatibilitySuppressions.xml). A stale +suppression fails the pack (`ApiCompatPermitUnnecessarySuppressions` stays false), strict mode stays off so additions +remain legal, and CI never regenerates, relaxes or bypasses the file (`CESDK9006`). Only the integrator regenerates it, +locally, after each rebase: + +```powershell +dotnet pack src/CheatEngine.SDK -c Release -p:ApiCompatGenerateSuppressionFile=true -o artifacts/nuget-regenerate +``` + +The resulting diff must equal the union of the breaks the integrated lots declared. Because the file declares +intentional breaks, `CESDK9007` requires the package major to exceed the baseline major: the line is 2.0 +(`MinVerMinimumMajorMinor`), so untagged commits pack as `2.0.0-alpha.0.N` with `AssemblyVersion` 2.0.0.0. + +Three records must agree (`CompatibilitySuppressionTests`): the baseline suppressions, the `*REMOVED*` lines, and +[`apicompat-invisible-changes.txt`](apicompat-invisible-changes.txt). Every suppression names a removed line of its +library, and every removed line is suppressed or listed as invisible with a reason. Matching is by declaring type and +member name, not by overload. Only CP0001, CP0002 and CP0011 are accepted today, because their trace in the PublicAPI +files is known; another rule id needs a reviewed extension of the test. + +The breaks against 1.0.0 at the time of writing: + +| Target | Rule | Change | +|---|---|---| +| `AddressResolutionOptions` constructor, `UseHostSymbolTable` accessors, `Deconstruct` | CP0002 | `UseHostSymbolTable` removed | +| `AddressListPluginInit.Callback`, `DisassemblerContextPluginInit.Callback` | CP0002 | field type changed from a typed function pointer to `void*` (ApiCompat reports a field-type change as a missing member) | +| `MemoryAccessFailure.DestinationTooSmall`, `.WriteFailed`, `.InvalidResult` | CP0011 | renumbered 4, 5, 6 to 5, 8, 9 | + +Attribute changes are invisible to both tools (the attribute rules CP0014-CP0016 are off by default): +`LuaClassAttribute` and `LuaPropertyAttribute` lost `Inherited = false`, and `MemoryScanSession.Scanner` and `.Results` +gained `[RequiresPluginEnabled]`. They are recorded in the header of the invisible-changes file and belong in the +release notes. + +## Declared breaks and the Client + +The Client consumes CheatEngine.SDK 1.0.0 and stays on it until it migrates to 2.x. Any break on a type it consumes is a +Client public break, or a Client behavior change for an enum it translates (audit A11-19, A20-Q48-2). + +- [`client-consumed-sdk-types.txt`](client-consumed-sdk-types.txt) copies the Client's list with its commit. The SDK never + reads the Client repository; the orchestrator or the integrator refreshes this copy when the Client list changes. A + name that exists neither in 1.0.0 nor now is marked `# unresolved (reason)`, and the test fails when a marked name + starts resolving or an unmarked one stops. +- [`client-induced-breaks.txt`](client-induced-breaks.txt) is derived: every baseline suppression whose containing type + is consumed. The test prints the expected lines when the file drifts. The Client turns this list into the "Client + impact" section of its `docs/migration/sdk-2.0.md`. + +This is SDK-side C0 evidence for Q48 (`CompatibilitySuppressionTests` carries `Qualification=Q48`). It never closes Q48 +at C1 or C3, which need the Client's consumer-contract tests, and it never closes F05: nothing is published from this +branch. + +## Enum contracts + +`EnumContractTests` reads enum members from the PublicAPI files (`T.M = -> T`), which RS0016/RS0017 keep equal to +the code: + +- The nine `Engine.Enums` types that mirror Cheat Engine constants, plus `Runtime.CheatEngineArchitecture` and + `Runtime.TargetAbi` (SDK decodings of Cheat Engine codes that appear in public Client signatures), keep their 1.0.0 + members. An added member needs a reviewed entry in the test. +- Every enum declared since 1.0.0 is classified (`StatusOrOutcome`, `Policy`, `ReasonOrEvidence`, `AbiDecision`), so a + new enum cannot skip the review. +- A `StatusOrOutcome` enum must not read as success when a value was never assigned: its zero member is `Unknown` + (tolerated: `Unspecified`, `Uninitialized`, `NotAttempted`). Eight enums still start with `Success` or `Released`; + they are listed with the lot that renumbers them (S-SCAN, S-RT, S-RES, S-GEN-A), and the list can only shrink. + +## Source versus package (1.0.0) + +The identities of the published baseline, as verified on 2026-09-23 (audit A22-04, SRC02-07, SRC03-01): + +| Identity | Value | Where it comes from | +|---|---|---| +| Tag | `v1.0.0`, annotated tag object `11a2b34f913b8814808b017c24134f8af535cd96` | `git rev-parse v1.0.0` | +| Commit | `a6fefb93e9c6f85a1bcedb68bf97e6741175b227` | the tag's commit, equal to the nuspec `` of the package | +| Tree | `41678f939547b2215e106ee3bbc8c2878815652e` | `git rev-parse v1.0.0^{tree}` | +| NuGet content hash (SHA-512) | `n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==` | `.nupkg.metadata` of the extracted package, equal to the Client's `packages.lock.json` | +| nuget.org signed file | SHA-256 `3e8c98583ac71af25a5bd7053e7583fbafcd196139fae7c0b04bae9b40a7cd33` | the downloaded `.nupkg` (repository-signed by nuget.org) | +| Public surface | 155 types, 1375 members in 7 assemblies | `PublicAPI.Shipped.txt` of the six libraries (the umbrella `CheatEngine.SDK.dll` declares no type) | +| Changes since | the `*REMOVED*` lines and additions of `PublicAPI.Unshipped.txt`, the suppression file | this folder | + +## Toolchain pin + +[`global.json`](../../global.json) requires .NET SDK 10.0.401 exactly (`rollForward: disable`) and names the install +command in `sdk.errorMessage`. Lock files record the SDK's implicit packages (ILLink, ILCompiler), so the SDK and the +locks move together ([global.json](https://learn.microsoft.com/dotnet/core/tools/global-json#rollforward)). The +analysis level is pinned next to it in [`Directory.Build.props`](../../Directory.Build.props) (`10.0-recommended`, +never `latest`), and `CESDK9004` fails an override. + +Raising the SDK is one pull request: update `global.json` (version and error message), raise the analysis-level pin if +the major or minor changed, fix the new diagnostics, regenerate the lock files, and update the CI setup that installs +the SDK. `ToolchainPinTests` keeps the version, error message and pin consistent. + +## NuGet audit + +Restore audits every package, direct and transitive, at every severity (`NuGetAudit`, `NuGetAuditMode` `all`, +`NuGetAuditLevel` `low`), following the documented +[dedicated audit pipeline](https://learn.microsoft.com/nuget/concepts/auditing-packages#running-nuget-audit-in-ci) +pattern: + +| Code | Meaning | Ordinary build | `restore -p:AuditPipeline=true` | +|---|---|---|---| +| NU1903, NU1904 | high, critical advisory | error (appended to `WarningsAsErrors`) | error | +| NU1901, NU1902 | low, moderate advisory | warning | error | +| NU1900, NU1905 | audit source unreachable or without vulnerability data | warning | error | + +The strict run is the scheduled health workflow. CI solution restores also assert, through +[`Directory.Solution.targets`](../../Directory.Solution.targets), that every project of the solution was audited or up +to date. `CESDK9009` fails a project that weakens the policy (audit off, another mode or level, a blocking code in +`NoWarn` or `WarningsNotAsErrors`, or a replaced `WarningsAsErrors`). + +An advisory can be excluded only as a last resort, in `Directory.Build.props`, with one item per advisory URL: + +```xml + +``` + +`CESDK9009` refuses a suppression declared anywhere else or without both metadata, the strict run fails once `Expires` +is past, and a stable (release) version cannot be packed while any suppression exists: the release path never +suppresses. + +## Repository guards + +| Id | Fails when | Fix | +|---|---|---| +| CESDK9003 | a `libs/` project lacks `PublicAPI.Shipped.txt` or `PublicAPI.Unshipped.txt` | add both files (header `#nullable enable`), then declare the API | +| CESDK9004 | a project's `AnalysisLevel` differs from the pin | remove the override, or raise the pin together with `global.json` | +| CESDK9006 | package validation is off, has no baseline or runs in strict mode; a CPxxxx or PKVxxx code is in `NoWarn`; or a CI pack regenerates, permits unnecessary, or bypasses suppressions or the baseline | restore the settings; regenerate the suppression file locally (integrator) | +| CESDK9007 | the suppression file declares baseline breaks but the package major does not exceed the baseline major | raise `MinVerMinimumMajorMinor`, or remove the break | +| CESDK9009 | the NuGet audit policy is weakened, a suppression is misplaced, incomplete or expired (strict run), a release is packed with a suppression, or a CI solution restore did not audit every project | restore the policy; fix or upgrade the package; complete or remove the suppression | diff --git a/eng/api/apicompat-invisible-changes.txt b/eng/api/apicompat-invisible-changes.txt new file mode 100644 index 00000000..5ae4929e --- /dev/null +++ b/eng/api/apicompat-invisible-changes.txt @@ -0,0 +1,17 @@ +# Declared API changes that ApiCompat does not report (audit A01-02: ApiCompat breaks = CompatibilitySuppressions.xml = +# the *REMOVED* lines of PublicAPI.Unshipped.txt, except for this reviewed list). +# +# Format: " | ". Typical entries: a +# nullable-annotation change (a new PublicAPI line, no binary break) or a const value change. Each entry must name an +# existing *REMOVED* line that no suppression covers, so a stale or redundant entry fails +# tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs. +# +# Verified 2026-09-23 against the 1.0.0 baseline: the field-type change of AddressListPluginInit.Callback and +# DisassemblerContextPluginInit.Callback (typed function pointer -> void*) IS reported by ApiCompat (CP0002 on the F: +# DocId), so every *REMOVED* line is currently covered by a suppression and this list has no entry. +# +# Changes invisible to BOTH PublicAPI and ApiCompat (attribute rules CP0014-CP0016 are off by default, +# ApiCompatEnableRuleAttributesMustMatch=false), recorded here and in the release notes, not as entries: +# - CheatEngine.SDK.Annotations.Lua.LuaClassAttribute: [AttributeUsage] lost Inherited = false (now inherited). +# - CheatEngine.SDK.Annotations.Lua.LuaPropertyAttribute: [AttributeUsage] lost Inherited = false (now inherited). +# - CheatEngine.SDK.Engine.Scanning.Values.MemoryScanSession.Scanner and .Results gained [RequiresPluginEnabled]. diff --git a/eng/api/client-consumed-sdk-types.txt b/eng/api/client-consumed-sdk-types.txt new file mode 100644 index 00000000..a70b57a3 --- /dev/null +++ b/eng/api/client-consumed-sdk-types.txt @@ -0,0 +1,44 @@ +# CheatEngine.SDK types the Client consumes: input of the Client-impact flag (audit A20-Q48-2, A11-19). +# +# Provenance: CheatEngineNet/CheatEngine.Client @ 881c14c146804b67944790e4db59ba7f96b19a24 +# (a) tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs:26-50, ApprovedSdkValueTypes: the SDK types +# allowed in public Client signatures (22 names). +# (b) SDK enums the Client translates in its libraries (libs/CheatEngine.Client.Core and .Abstractions): a renumbering +# changes Client behavior even though the enum is not in a public Client signature. +# +# Refresh rule: the orchestrator or the integrator refreshes this file whenever the Client allowlist or the set of +# translated enums changes, and cites the new Client commit above. The SDK never reads the Client repository at build or +# test time; tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs reads only this file. +# +# Format: one full type name per line, ordinal order inside each section. A trailing "# unresolved (reason)" marks a +# name that exists neither in the published 1.0.0 surface nor in the current declared API; the test fails if a marked +# name starts resolving or an unmarked name stops resolving. + +# (a) Allowlisted in public Client signatures +CheatEngine.SDK.Engine.AddressList.MemoryRecordId +CheatEngine.SDK.Engine.Enums.FastScanMethod +CheatEngine.SDK.Engine.Enums.VariableType +CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions +CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo +CheatEngine.SDK.Engine.Inspection.ModuleInfo +CheatEngine.SDK.Engine.Inspection.ModuleName +CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo +CheatEngine.SDK.Engine.Inspection.SymbolExpression +CheatEngine.SDK.Engine.Inspection.SymbolInfo +CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture +CheatEngine.SDK.Engine.Runtime.CheatEngineVersion +CheatEngine.SDK.Engine.Runtime.PointerSize +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability +CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId +CheatEngine.SDK.Engine.Runtime.TargetAbi +CheatEngine.SDK.Engine.Scanning.Aob.AobPattern # unresolved (stale Client entry; C-CORE-A) +CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions +CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest +CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest +CheatEngine.SDK.Engine.Values.Address + +# (b) Translated by the Client +CheatEngine.SDK.Engine.Errors.EngineFailureKind +CheatEngine.SDK.Engine.Inspection.InspectionStatus +CheatEngine.SDK.Engine.Memory.MemoryAccessFailure diff --git a/eng/api/client-induced-breaks.txt b/eng/api/client-induced-breaks.txt new file mode 100644 index 00000000..170cb49b --- /dev/null +++ b/eng/api/client-induced-breaks.txt @@ -0,0 +1,18 @@ +# Intentional breaks against CheatEngine.SDK 1.0.0 that touch a type the Client consumes (audit A20-Q48-2, A11-19). +# +# Derived, not hand-written: every baseline suppression of src/CheatEngine.SDK/CompatibilitySuppressions.xml whose +# containing type (the type itself for a T: target) is listed in client-consumed-sdk-types.txt. Format: +# " | ", ordinal order. The Client consumes this list for the +# "Client impact" section of its docs/migration/sdk-2.0.md; every entry is a Client public break or a Client behavior +# change once the Client moves to SDK 2.x. +# +# Regenerate it whenever the integrator regenerates CompatibilitySuppressions.xml or the consumed list changes: +# tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs fails and prints the expected lines. +# This is SDK-side C0 evidence for Q48 only; it never closes Q48 at C1/C3 and never closes F05. +F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall | CP0011 +F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult | CP0011 +F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed | CP0011 +M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.#ctor(System.Boolean,System.Boolean) | CP0002 +M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Deconstruct(System.Boolean@,System.Boolean@) | CP0002 +M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.get_UseHostSymbolTable | CP0002 +M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.set_UseHostSymbolTable(System.Boolean) | CP0002 diff --git a/src/CheatEngine.SDK/CheatEngine.SDK.csproj b/src/CheatEngine.SDK/CheatEngine.SDK.csproj index 73b4cd4e..10edd3f3 100644 --- a/src/CheatEngine.SDK/CheatEngine.SDK.csproj +++ b/src/CheatEngine.SDK/CheatEngine.SDK.csproj @@ -10,6 +10,13 @@ cheat-engine;cheat-engine-plugin;sdk;lua;source-generator;roslyn-analyzer;reverse-engineering $(PackageProjectUrl)/blob/main/CHANGELOG.md true + + 1.0.0 $(TargetsForTfmSpecificBuildOutput);_CheatEngineSdkEmbedLibraries $(TargetsForTfmSpecificContentInPackage);_CheatEngineSdkPackRoslynComponents diff --git a/src/CheatEngine.SDK/CompatibilitySuppressions.xml b/src/CheatEngine.SDK/CompatibilitySuppressions.xml new file mode 100644 index 00000000..bbb20b27 --- /dev/null +++ b/src/CheatEngine.SDK/CompatibilitySuppressions.xml @@ -0,0 +1,67 @@ + + + + + CP0002 + F:CheatEngine.SDK.Abi.Native.AddressListPluginInit.Callback + lib/net10.0/CheatEngine.SDK.Abi.dll + lib/net10.0/CheatEngine.SDK.Abi.dll + true + + + CP0002 + F:CheatEngine.SDK.Abi.Native.DisassemblerContextPluginInit.Callback + lib/net10.0/CheatEngine.SDK.Abi.dll + lib/net10.0/CheatEngine.SDK.Abi.dll + true + + + CP0002 + M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.#ctor(System.Boolean,System.Boolean) + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0002 + M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.Deconstruct(System.Boolean@,System.Boolean@) + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0002 + M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.get_UseHostSymbolTable + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0002 + M:CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions.set_UseHostSymbolTable(System.Boolean) + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0011 + F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.DestinationTooSmall + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0011 + F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.InvalidResult + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + + CP0011 + F:CheatEngine.SDK.Engine.Memory.MemoryAccessFailure.WriteFailed + lib/net10.0/CheatEngine.SDK.Engine.dll + lib/net10.0/CheatEngine.SDK.Engine.dll + true + + \ No newline at end of file diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/ApiContractFiles.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/ApiContractFiles.cs new file mode 100644 index 00000000..a60bda19 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/ApiContractFiles.cs @@ -0,0 +1,98 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// Readers for the committed API contract files: the suppression file and the eng/api/*.txt lists. +internal static class ApiContractFiles +{ + public const string SuppressionFile = "src/CheatEngine.SDK/CompatibilitySuppressions.xml"; + public const string InvisibleChangesFile = "eng/api/apicompat-invisible-changes.txt"; + public const string ClientConsumedTypesFile = "eng/api/client-consumed-sdk-types.txt"; + public const string ClientInducedBreaksFile = "eng/api/client-induced-breaks.txt"; + + /// Assemblies embedded under lib/net10.0: the umbrella plus the six shipping libraries. + public static IReadOnlySet PackageAssemblies + { + get + { + HashSet assemblies = new(StringComparer.Ordinal) { "CheatEngine.SDK" }; + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + assemblies.Add(library.Name); + } + + return assemblies; + } + } + + public static IReadOnlyList ReadSuppressions() + { + XDocument document = XDocument.Load(FullPath(SuppressionFile)); + List suppressions = []; + foreach (XElement suppression in document.Root!.Elements("Suppression")) + { + suppressions.Add(new CompatibilitySuppression( + Value(suppression, "DiagnosticId"), + Value(suppression, "Target"), + Value(suppression, "Left"), + Value(suppression, "Right"), + string.Equals(Value(suppression, "IsBaselineSuppression"), "true", StringComparison.Ordinal))); + } + + return suppressions; + } + + /// Non-blank lines of a list file that are not whole-line # comments, trimmed. + public static IReadOnlyList ReadEntries(string relativePath) + { + List entries = []; + foreach (string line in File.ReadAllLines(FullPath(relativePath))) + { + string trimmed = line.Trim(); + if (trimmed.Length != 0 && !trimmed.StartsWith('#')) + { + entries.Add(trimmed); + } + } + + return entries; + } + + /// eng/api/client-consumed-sdk-types.txt: type name, and whether it is marked unresolved. + public static IReadOnlyList<(string TypeName, bool MarkedUnresolved)> ReadClientConsumedTypes() + { + List<(string, bool)> types = []; + foreach (string entry in ReadEntries(ClientConsumedTypesFile)) + { + int comment = entry.IndexOf(" #", StringComparison.Ordinal); + string name = comment < 0 ? entry : entry[..comment].TrimEnd(); + bool unresolved = comment >= 0 && entry[comment..].Contains("# unresolved (", StringComparison.Ordinal); + types.Add((name, unresolved)); + } + + return types; + } + + /// eng/api/apicompat-invisible-changes.txt: the exact *REMOVED* line and its reason. + public static IReadOnlyList<(string RemovedLine, string Reason)> ReadInvisibleChanges() + { + List<(string, string)> changes = []; + foreach (string entry in ReadEntries(InvisibleChangesFile)) + { + int separator = entry.LastIndexOf(" | ", StringComparison.Ordinal); + changes.Add(separator < 0 ? (entry, "") : (entry[..separator], entry[(separator + 3)..].Trim())); + } + + return changes; + } + + private static string Value(XElement parent, string name) + { + return parent.Element(name)?.Value.Trim() ?? ""; + } + + private static string FullPath(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppression.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppression.cs new file mode 100644 index 00000000..d537f37e --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppression.cs @@ -0,0 +1,13 @@ +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// One <Suppression> of src/CheatEngine.SDK/CompatibilitySuppressions.xml. +internal sealed record CompatibilitySuppression( + string DiagnosticId, + string Target, + string Left, + string Right, + bool IsBaselineSuppression) +{ + /// The line this suppression contributes to eng/api/client-induced-breaks.txt. + public string InducedBreakLine => $"{Target} | {DiagnosticId}"; +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs new file mode 100644 index 00000000..a275de4f --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs @@ -0,0 +1,195 @@ +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// The three records of intentional breaks against CheatEngine.SDK 1.0.0 agree: the ApiCompat baseline suppressions +/// of src/CheatEngine.SDK/CompatibilitySuppressions.xml (what the pack-time package validation reports), the +/// *REMOVED* lines of the PublicAPI.Unshipped.txt files (what the build's RS0017 reports), and the +/// reviewed list of changes ApiCompat cannot see. Suppressions that touch a type the Client consumes are listed in +/// eng/api/client-induced-breaks.txt. +/// +/// +/// A suppression and a removed line are related by declaring type and member name (see +/// ), not by overload: removing one overload of a method whose other overload was +/// also removed cannot be told apart, which is acceptable because both are declared breaks. A DocId is mapped as +/// follows: M:T.#ctor(...) to the constructor line T.<SimpleName>(, M:T.get_N, +/// M:T.set_N and P:T.N to T.N.get/.set/.init, F:T.N to T.N -> or the +/// enum member T.N = v ->, and T:X to the type line X. Only rule ids with such a mapping are +/// accepted (CP0001, CP0002, CP0011); any other rule (for example CP0005, CP0006, CP0009 or CP0019) needs a reviewed +/// extension of this class, because its trace in the PublicAPI files differs. This is SDK-side C0 evidence for Q48; it +/// never closes Q48 at C1/C3 and never closes F05. +/// +[Trait("Qualification", "Q48")] +public sealed class CompatibilitySuppressionTests +{ + private static readonly HashSet s_mappedRuleIds = new(StringComparer.Ordinal) { "CP0001", "CP0002", "CP0011" }; + + [Fact] + public void Every_suppression_is_a_baseline_suppression_of_one_library_against_itself() + { + IReadOnlySet assemblies = ApiContractFiles.PackageAssemblies; + foreach (CompatibilitySuppression suppression in ApiContractFiles.ReadSuppressions()) + { + string where = $"{suppression.DiagnosticId} {suppression.Target}"; + Assert.True(suppression.IsBaselineSuppression, $"{where} is not a baseline suppression."); + Assert.True(string.Equals(suppression.Left, suppression.Right, StringComparison.Ordinal), + $"{where} compares {suppression.Left} with {suppression.Right}."); + Assert.True(s_mappedRuleIds.Contains(suppression.DiagnosticId), + $"{where}: {suppression.DiagnosticId} is not an accepted rule id ({string.Join(", ", s_mappedRuleIds)}). Review the break, then extend {nameof(CompatibilitySuppressionTests)}."); + Assert.True(assemblies.Contains(AssemblyOf(suppression)), + $"{where} names '{suppression.Left}', which is not lib/net10.0/.dll."); + } + } + + [Fact] + public void Every_baseline_suppression_matches_a_removed_public_api_line() + { + Dictionary libraries = LibrariesByName(); + foreach (CompatibilitySuppression suppression in ApiContractFiles.ReadSuppressions()) + { + string assembly = AssemblyOf(suppression); + Assert.True(libraries.TryGetValue(assembly, out PublicApiLibrary? library), + $"{suppression.Target}: '{assembly}' has no PublicAPI files, so the break cannot be declared there."); + + IReadOnlySet candidates = PublicApiDeclarations.CandidateKeysOf(suppression.Target); + bool declared = false; + foreach (string removed in library.Removed) + { + declared |= candidates.Contains(PublicApiDeclarations.KeyOf(removed)); + } + + Assert.True(declared, + $"{suppression.DiagnosticId} {suppression.Target} has no '*REMOVED*' line in {library.UnshippedPath} (looked for {string.Join(", ", candidates)})."); + } + } + + [Fact] + public void Every_removed_public_api_line_is_suppressed_or_declared_invisible_to_apicompat() + { + HashSet invisible = new(StringComparer.Ordinal); + foreach ((string removedLine, _) in ApiContractFiles.ReadInvisibleChanges()) + { + invisible.Add(removedLine); + } + + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + HashSet suppressedKeys = SuppressedKeys(library.Name); + foreach (string removed in library.Removed) + { + bool suppressed = suppressedKeys.Contains(PublicApiDeclarations.KeyOf(removed)); + bool declaredInvisible = invisible.Contains(PublicApiLibrary.RemovedPrefix + removed); + Assert.True(suppressed || declaredInvisible, + $"{library.UnshippedPath}: '*REMOVED*{removed}' has no ApiCompat suppression. Regenerate CompatibilitySuppressions.xml, or add the line to {ApiContractFiles.InvisibleChangesFile} with the reason ApiCompat cannot see it."); + Assert.False(suppressed && declaredInvisible, + $"'*REMOVED*{removed}' is suppressed, so it must not also be listed in {ApiContractFiles.InvisibleChangesFile}."); + } + } + } + + [Fact] + public void Every_invisible_change_names_a_current_removed_line_with_a_reason() + { + HashSet removedLines = new(StringComparer.Ordinal); + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + foreach (string removed in library.Removed) + { + removedLines.Add(PublicApiLibrary.RemovedPrefix + removed); + } + } + + foreach ((string removedLine, string reason) in ApiContractFiles.ReadInvisibleChanges()) + { + Assert.True(removedLines.Contains(removedLine), + $"{ApiContractFiles.InvisibleChangesFile}: '{removedLine}' is not a '*REMOVED*' line of any PublicAPI.Unshipped.txt (stale entry)."); + Assert.False(string.IsNullOrWhiteSpace(reason), + $"{ApiContractFiles.InvisibleChangesFile}: '{removedLine}' needs ' | '."); + } + } + + [Fact] + public void Suppressions_touching_client_consumed_types_are_listed_as_induced_client_breaks() + { + HashSet consumed = new(StringComparer.Ordinal); + foreach ((string typeName, _) in ApiContractFiles.ReadClientConsumedTypes()) + { + consumed.Add(typeName); + } + + List expected = []; + foreach (CompatibilitySuppression suppression in ApiContractFiles.ReadSuppressions()) + { + if (consumed.Contains(PublicApiDeclarations.ContainingTypeOf(suppression.Target))) + { + expected.Add(suppression.InducedBreakLine); + } + } + + expected.Sort(StringComparer.Ordinal); + IReadOnlyList listed = ApiContractFiles.ReadEntries(ApiContractFiles.ClientInducedBreaksFile); + Assert.True(expected.SequenceEqual(listed, StringComparer.Ordinal), + $"{ApiContractFiles.ClientInducedBreaksFile} must list exactly (ordinal order):{Environment.NewLine}{string.Join(Environment.NewLine, expected)}"); + } + + [Fact] + public void Every_client_consumed_type_resolves_in_the_declared_api_or_is_marked_unresolved() + { + HashSet declaredTypes = new(StringComparer.Ordinal); + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + foreach (string line in library.Shipped.Concat(library.Declared)) + { + if (PublicApiDeclarations.IsTypeLine(line)) + { + declaredTypes.Add(PublicApiDeclarations.KeyOf(line)); + } + } + } + + IReadOnlyList<(string TypeName, bool MarkedUnresolved)> consumed = ApiContractFiles.ReadClientConsumedTypes(); + Assert.NotEmpty(consumed); + Assert.Equal(consumed.Count, consumed.Select(static c => c.TypeName).Distinct(StringComparer.Ordinal).Count()); + foreach ((string typeName, bool markedUnresolved) in consumed) + { + bool resolves = declaredTypes.Contains(typeName); + Assert.True(resolves != markedUnresolved, + markedUnresolved + ? $"{typeName} is marked '# unresolved' but is declared now: remove the marker." + : $"{typeName} is neither in the 1.0.0 surface nor in the declared API: fix the name or mark it '# unresolved (reason)'."); + } + } + + private static string AssemblyOf(CompatibilitySuppression suppression) + { + const string prefix = "lib/net10.0/"; + string left = suppression.Left; + return left.StartsWith(prefix, StringComparison.Ordinal) && left.EndsWith(".dll", StringComparison.Ordinal) + ? left[prefix.Length..^".dll".Length] + : left; + } + + private static Dictionary LibrariesByName() + { + Dictionary libraries = new(StringComparer.Ordinal); + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + libraries.Add(library.Name, library); + } + + return libraries; + } + + private static HashSet SuppressedKeys(string assembly) + { + HashSet keys = new(StringComparer.Ordinal); + foreach (CompatibilitySuppression suppression in ApiContractFiles.ReadSuppressions()) + { + if (string.Equals(AssemblyOf(suppression), assembly, StringComparison.Ordinal)) + { + keys.UnionWith(PublicApiDeclarations.CandidateKeysOf(suppression.Target)); + } + } + + return keys; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/EnumContractTests.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/EnumContractTests.cs new file mode 100644 index 00000000..c9e4e2b3 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/EnumContractTests.cs @@ -0,0 +1,239 @@ +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// Enum contracts, read from the PublicAPI files (an enum member is the line T.M = <int> -> T). With +/// RS0016/RS0017 enforced by the build, a PublicAPI line cannot drift from the code, so these text checks freeze +/// values: enums that mirror Cheat Engine constants or appear in public Client signatures keep their 1.0.0 members, +/// every enum added since 1.0.0 is classified, and SDK-owned status/outcome enums do not read as success when a value +/// was never assigned (Unknown = 0 hygiene, audit A11-20). +/// +public sealed class EnumContractTests +{ + /// + /// Frozen at their 1.0.0 members. The nine Enums/* types mirror Cheat Engine constants (their values are + /// also pinned by tests/CheatEngine.SDK.Engine.Tests/Enums/EnumValueTests.cs); CheatEngineArchitecture + /// and TargetAbi are SDK decodings of Cheat Engine codes that appear in public Client signatures and that no + /// other test pins numerically. + /// + private static readonly SortedSet s_frozenEnums = new(StringComparer.Ordinal) + { + "CheatEngine.SDK.Engine.Enums.BreakpointMethod", + "CheatEngine.SDK.Engine.Enums.BreakpointTrigger", + "CheatEngine.SDK.Engine.Enums.ContinueMethod", + "CheatEngine.SDK.Engine.Enums.DuplicateHandling", + "CheatEngine.SDK.Engine.Enums.FastScanMethod", + "CheatEngine.SDK.Engine.Enums.MemoryProtection", + "CheatEngine.SDK.Engine.Enums.RoundingType", + "CheatEngine.SDK.Engine.Enums.ScanOption", + "CheatEngine.SDK.Engine.Enums.VariableType", + "CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.SDK.Engine.Runtime.TargetAbi" + }; + + /// Reviewed additions to a frozen enum (for example a new Cheat Engine constant), as "Type.Member". Empty. + private static readonly HashSet s_reviewedFrozenEnumAdditions = new(StringComparer.Ordinal); + + /// Every enum type declared since 1.0.0, with its category. A new enum must be added here. + private static readonly Dictionary s_addedEnums = new(StringComparer.Ordinal) + { + ["CheatEngine.SDK.Abi.Native.DebugEventDecision"] = EnumCategory.AbiDecision, + ["CheatEngine.SDK.Abi.Native.DebugEventObservationOverflowPolicy"] = EnumCategory.Policy, + ["CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationEffect"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.AddressList.MemoryRecordMutationProblem"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Allocation.TargetMemoryOperationOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Aob.AobScanOutcomeKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCancellationMilestone"] = EnumCategory.ReasonOrEvidence, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanInvalidationReason"] = EnumCategory.ReasonOrEvidence, + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Targets.TargetIdentityCheckKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Targets.TargetIdentityEvidence"] = EnumCategory.ReasonOrEvidence, + ["CheatEngine.SDK.Engine.Targets.TargetReleaseStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Engine.Targets.TargetSelectionObservationStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.Registration.LuaRegistrationCollisionPolicy"] = EnumCategory.Policy, + ["CheatEngine.SDK.Lua.Registration.LuaRegistrationReleaseKind"] = EnumCategory.StatusOrOutcome, + ["CheatEngine.SDK.Lua.Registration.LuaRegistrationResultKind"] = EnumCategory.StatusOrOutcome + }; + + /// + /// Status/outcome enums whose zero member still reads as success, with the lot that renumbers them (a break, + /// declared through CompatibilitySuppressions.xml when the enum shipped, and a PublicAPI change). The list only + /// shrinks: fails once an entry is fixed. + /// + private static readonly Dictionary s_pendingZeroValueFixes = new(StringComparer.Ordinal) + { + ["CheatEngine.SDK.Engine.Assembly.InstructionOperationStatus"] = "S-RT", + ["CheatEngine.SDK.Engine.Inspection.SymbolRegistrationReleaseKind"] = "S-RES", + ["CheatEngine.SDK.Engine.Processes.ProcessOperationStatusKind"] = "S-RT", + ["CheatEngine.SDK.Engine.Scanning.Aob.AobScanStatus"] = "S-SCAN", + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanCreationStatus"] = "S-SCAN", + ["CheatEngine.SDK.Engine.Scanning.Values.MemoryScanMaterializationStatus"] = "S-SCAN", + ["CheatEngine.SDK.Lua.Calls.LuaOperationStatusKind"] = "S-GEN-A", + ["CheatEngine.SDK.Lua.CompilerServices.LuaGlobalPushStatus"] = "S-GEN-A" + }; + + /// Zero-member names that read as "it worked" (or, for a failure-kind enum, "no failure"). + private static readonly HashSet s_successLikeNames = new(StringComparer.Ordinal) + { + "Complete", "Completed", "Done", "None", "Ok", "Released", "Succeeded", "Success", "Successful" + }; + + /// + /// Neutral zero members for a status/outcome enum: new enums use Unknown; the other names are tolerated + /// because enums already use them with the same "nothing established yet" meaning. + /// + private static readonly HashSet s_neutralZeroNames = new(StringComparer.Ordinal) + { + "Unknown", "Unspecified", "Uninitialized", "NotAttempted" + }; + + private enum EnumCategory + { + Unknown = 0, + StatusOrOutcome, + Policy, + ReasonOrEvidence, + AbiDecision + } + + [Fact] + public void Enums_mirroring_cheat_engine_constants_or_client_signatures_keep_their_1_0_0_members() + { + Dictionary> shipped = EnumMembers(static l => l.Shipped); + foreach (string frozen in s_frozenEnums) + { + Assert.True(shipped.ContainsKey(frozen), $"{frozen} is not an enum of the 1.0.0 surface."); + } + + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + foreach (string removed in library.Removed) + { + Assert.False(IsMemberOfFrozenEnum(removed, out string enumType), + $"{library.UnshippedPath}: '*REMOVED*{removed}' changes the frozen enum {enumType}."); + } + + foreach (string added in library.Added) + { + if (IsMemberOfFrozenEnum(added, out string enumType) + && PublicApiDeclarations.TryParseEnumMember(added, out _, out string member, out _)) + { + Assert.True(s_reviewedFrozenEnumAdditions.Contains($"{enumType}.{member}"), + $"{library.UnshippedPath}: '{added}' adds a member to the frozen enum {enumType} without a reviewed entry."); + } + } + } + } + + [Fact] + public void Every_enum_added_after_1_0_0_is_classified() + { + Dictionary> shipped = EnumMembers(static l => l.Shipped); + SortedSet added = new(StringComparer.Ordinal); + foreach (string enumType in EnumMembers(static l => l.Declared).Keys) + { + if (!shipped.ContainsKey(enumType)) + { + added.Add(enumType); + } + } + + SortedSet classified = new(s_addedEnums.Keys, StringComparer.Ordinal); + Assert.True(added.SetEquals(classified), + $"Classify every enum declared since 1.0.0 in {nameof(s_addedEnums)}. Missing: {string.Join(", ", added.Except(classified, StringComparer.Ordinal))}. Stale: {string.Join(", ", classified.Except(added, StringComparer.Ordinal))}."); + Assert.DoesNotContain(EnumCategory.Unknown, s_addedEnums.Values); + } + + [Fact] + public void Status_and_outcome_enums_added_after_1_0_0_do_not_default_to_success() + { + Dictionary> declared = EnumMembers(static l => l.Declared); + foreach ((string enumType, EnumCategory category) in s_addedEnums) + { + if (category != EnumCategory.StatusOrOutcome || s_pendingZeroValueFixes.ContainsKey(enumType)) + { + continue; + } + + string? zero = ZeroMember(declared[enumType]); + Assert.True(zero is not null, $"{enumType} has no member with value 0, so default({SimpleName(enumType)}) has no name."); + Assert.False(s_successLikeNames.Contains(zero), $"{enumType}.{zero} = 0 reads as success."); + Assert.True(s_neutralZeroNames.Contains(zero), + $"{enumType}.{zero} = 0: a status/outcome enum starts with Unknown = 0 (tolerated: {string.Join(", ", s_neutralZeroNames)})."); + } + } + + [Fact] + public void Pending_zero_value_fixes_are_still_needed() + { + Dictionary> declared = EnumMembers(static l => l.Declared); + foreach ((string enumType, string owner) in s_pendingZeroValueFixes) + { + Assert.True(s_addedEnums.TryGetValue(enumType, out EnumCategory category) && category == EnumCategory.StatusOrOutcome, + $"{enumType} is pending but not classified as {EnumCategory.StatusOrOutcome}."); + Assert.False(string.IsNullOrWhiteSpace(owner)); + string? zero = declared.TryGetValue(enumType, out SortedDictionary? members) ? ZeroMember(members) : null; + Assert.True(zero is not null && s_successLikeNames.Contains(zero), + $"{enumType} no longer starts with a success-like member ({zero ?? "no zero member"}): {owner} fixed it, so remove it from {nameof(s_pendingZeroValueFixes)}."); + } + } + + private static bool IsMemberOfFrozenEnum(string line, out string enumType) + { + if (PublicApiDeclarations.TryParseEnumMember(line, out enumType, out _, out _)) + { + return s_frozenEnums.Contains(enumType); + } + + enumType = ""; + return false; + } + + private static Dictionary> EnumMembers( + Func> lines) + { + Dictionary> enums = new(StringComparer.Ordinal); + foreach (PublicApiLibrary library in PublicApiLibrary.LoadAll()) + { + foreach (string line in lines(library)) + { + if (PublicApiDeclarations.TryParseEnumMember(line, out string enumType, out string member, out long value)) + { + if (!enums.TryGetValue(enumType, out SortedDictionary? members)) + { + members = new SortedDictionary(StringComparer.Ordinal); + enums.Add(enumType, members); + } + + members[member] = value; + } + } + } + + return enums; + } + + private static string? ZeroMember(SortedDictionary members) + { + foreach ((string member, long value) in members) + { + if (value == 0) + { + return member; + } + } + + return null; + } + + private static string SimpleName(string typeName) + { + return typeName[(typeName.LastIndexOf('.') + 1)..]; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiDeclarations.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiDeclarations.cs new file mode 100644 index 00000000..3c431de0 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiDeclarations.cs @@ -0,0 +1,194 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace CheatEngine.SDK.Repository.Tests.PublicApi; + +/// +/// Name-level reading of PublicAPI declaration lines and of ApiCompat documentation ids (DocIds), enough to relate a +/// compatibility suppression to the declaration it concerns. Matching is by declaring type and member name, not by +/// overload: two overloads of one method share a key. Explicit interface implementations are not mapped. +/// +internal static partial class PublicApiDeclarations +{ + private static readonly string[] s_modifiers = + ["abstract", "const", "extern", "new", "override", "readonly", "required", "sealed", "static", "virtual"]; + + /// + /// The declared name of a PublicAPI line: modifiers, the oblivious marker ~, generic argument lists, + /// parameters, the return type and an enum value are dropped. T.M(int x) -> void gives T.M, + /// T.P.get -> int gives T.P.get, T.this[int i].get -> byte gives T.this, + /// static T.operator ==(...) gives T.operator and T.M = 4 -> T gives T.M. + /// + public static string KeyOf(string line) + { + string text = line.StartsWith(PublicApiLibrary.RemovedPrefix, StringComparison.Ordinal) + ? line[PublicApiLibrary.RemovedPrefix.Length..] + : line; + text = text.TrimStart('~'); + bool stripped; + do + { + stripped = false; + foreach (string modifier in s_modifiers) + { + if (text.StartsWith(modifier + " ", StringComparison.Ordinal)) + { + text = text[(modifier.Length + 1)..]; + stripped = true; + } + } + } while (stripped); + + StringBuilder key = new(text.Length); + int depth = 0; + foreach (char character in text) + { + if (character == '<') + { + depth++; + } + else if (character == '>') + { + depth--; + } + else if (depth == 0) + { + if (character is ' ' or '(' or '[') + { + break; + } + + key.Append(character); + } + } + + return key.ToString(); + } + + /// Whether a PublicAPI line declares a type (a bare, possibly generic, type name). + public static bool IsTypeLine(string line) + { + return !line.Contains(" -> ", StringComparison.Ordinal) && !line.Contains('(', StringComparison.Ordinal); + } + + /// The PublicAPI keys (see ) a DocId can name. + public static IReadOnlySet CandidateKeysOf(string docId) + { + (char kind, string type, string member) = Split(docId); + HashSet keys = new(StringComparer.Ordinal); + if (kind == 'T') + { + keys.Add(type); + return keys; + } + + string accessorName = member.Length > 4 ? member[4..] : member; + switch (kind) + { + case 'M' when string.Equals(member, "#ctor", StringComparison.Ordinal): + keys.Add($"{type}.{SimpleName(type)}"); + break; + case 'M' when member.StartsWith("get_", StringComparison.Ordinal) + || member.StartsWith("set_", StringComparison.Ordinal): + AddPropertyKeys(keys, type, accessorName); + break; + case 'M' when member.StartsWith("add_", StringComparison.Ordinal): + keys.Add($"{type}.{accessorName}"); + break; + case 'M' when member.StartsWith("remove_", StringComparison.Ordinal): + keys.Add($"{type}.{member["remove_".Length..]}"); + break; + case 'M' when string.Equals(member, "op_Implicit", StringComparison.Ordinal): + keys.Add($"{type}.implicit"); + break; + case 'M' when string.Equals(member, "op_Explicit", StringComparison.Ordinal): + keys.Add($"{type}.explicit"); + break; + case 'M' when member.StartsWith("op_", StringComparison.Ordinal): + keys.Add($"{type}.operator"); + break; + case 'P': + AddPropertyKeys(keys, type, member); + break; + default: + keys.Add($"{type}.{member}"); + break; + } + + return keys; + } + + /// The full name of the type a DocId declares or belongs to, without generic arity. + public static string ContainingTypeOf(string docId) + { + return Split(docId).Type; + } + + /// + /// Parses an enum member line (T.M = 4 -> T). Constants (const T.C = 6 -> int) do not match + /// because their type differs from the declaring type. + /// + public static bool TryParseEnumMember(string line, out string enumType, out string member, out long value) + { + Match match = EnumMemberLine().Match(line); + if (!match.Success) + { + enumType = member = ""; + value = 0; + return false; + } + + enumType = match.Groups["type"].Value; + member = match.Groups["member"].Value; + value = long.Parse(match.Groups["value"].Value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + + private static void AddPropertyKeys(HashSet keys, string type, string property) + { + keys.Add($"{type}.{property}.get"); + keys.Add($"{type}.{property}.set"); + keys.Add($"{type}.{property}.init"); + if (string.Equals(property, "Item", StringComparison.Ordinal)) + { + keys.Add($"{type}.this"); + } + } + + private static (char Kind, string Type, string Member) Split(string docId) + { + if (docId.Length < 3 || docId[1] != ':') + { + throw new FormatException($"'{docId}' is not a documentation id."); + } + + char kind = docId[0]; + string body = docId[2..]; + int parameters = body.IndexOf('(', StringComparison.Ordinal); + if (parameters >= 0) + { + body = body[..parameters]; + } + + body = GenericArity().Replace(body, ""); + if (kind == 'T') + { + return (kind, body, ""); + } + + int dot = body.LastIndexOf('.'); + return (kind, body[..dot], body[(dot + 1)..]); + } + + private static string SimpleName(string type) + { + return type[(type.LastIndexOf('.') + 1)..]; + } + + [GeneratedRegex(@"`+\d+", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex GenericArity(); + + [GeneratedRegex(@"^(?[\w.]+)\.(?\w+) = (?-?\d+) -> \k$", RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 1000)] + private static partial Regex EnumMemberLine(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs index 841d630c..7ad821fd 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/PublicApi/PublicApiLibrary.cs @@ -79,6 +79,18 @@ public IEnumerable Removed } } + /// The current declared surface: Shipped without the removed declarations, plus the added ones. + public IReadOnlySet Declared + { + get + { + HashSet declared = new(Shipped, StringComparer.Ordinal); + declared.ExceptWith(Removed); + declared.UnionWith(Added); + return declared; + } + } + /// Every repository-relative libs/<name> directory that holds a project file. public static IReadOnlyList EnumerateLibraryDirectories() { diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index c9bdc808..45853cb7 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -18,6 +18,8 @@ this project only reads committed files. It never builds, packs, restores or sta | `Infrastructure/` | `RepositoryRoot` finds `CheatEngine.SDK.slnx` above the test binaries and enumerates source files. | | `Solution/` | `SolutionInventoryTests` compares the projects on disk with the projects listed in the solution. | | `Documentation/` | `DocumentationIntegrityTests` checks every Markdown file: links, anchors, paths, `docs/` pages. | +| `Toolchain/` | `ToolchainPinTests` reads `global.json`, `Directory.Build.props` and `Directory.Solution.targets`: exact SDK, analysis-level pin, NuGet audit policy. | +| `PublicApi/` | PublicAPI files, `CompatibilitySuppressions.xml` and the `eng/api/*.txt` lists: file shape, declared breaks, Client-induced breaks, enum contracts. | Later work adds one folder per contract (for example `Documentation/`, `Workflows/`, `Qualification/`). @@ -49,6 +51,37 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow (`No_markdown_file_claims_complete_coverage_or_universal_support`). - The Markdown parser and every rule are self-tested on in-memory pages, so each gate is shown to fail on the regression it exists for (`MarkdownDocumentTests`). +- The .NET SDK is pinned exactly: `rollForward: disable`, no prerelease, and an `errorMessage` naming the pinned version + and its install command (`Global_json_requires_the_exact_sdk_with_roll_forward_disabled`, + `Global_json_error_message_names_the_pinned_sdk_version`). +- The analysis level is a release-shaped pin that moves with the SDK major and minor, and no other MSBuild file sets it + (`Analysis_level_is_pinned_to_a_release_not_latest`, `Analysis_level_pin_moves_with_the_pinned_sdk_major_and_minor`, + `No_project_or_props_file_overrides_the_pinned_analysis_level`). +- High and critical NuGet advisories fail every restore, CI solution restores assert that every project was audited, + and advisory suppressions live only in `Directory.Build.props` with a justification and an expiry + (`Nuget_audit_blocks_high_and_critical_advisories_in_every_build`, + `Ci_solution_restores_assert_that_nuget_audit_covered_every_project`, + `Nuget_audit_suppressions_live_in_the_root_props_with_a_justification_and_an_expiry`). +- Each of the six shipping libraries, and nothing else, has both PublicAPI files, each starting with + `#nullable enable` and ordinally sorted without duplicates; Shipped never carries a removal marker, and every + `*REMOVED*` line repeats a Shipped line exactly (`Every_shipping_library_has_both_public_api_files`, + `Public_api_files_exist_only_next_to_shipping_libraries`, `Every_public_api_file_starts_with_nullable_enable`, + `Every_public_api_file_is_ordinally_sorted_after_its_header`, `Shipped_files_never_contain_removed_markers`, + `Every_removed_line_names_a_line_of_the_shipped_file`, `Unshipped_never_redeclares_a_live_shipped_line`). +- The ApiCompat baseline suppressions, the `*REMOVED*` lines and the reviewed list of changes ApiCompat cannot see + describe the same breaks against 1.0.0, and the suppressions that touch a Client-consumed type are exactly the listed + Client-induced breaks; SDK-side C0 evidence for Q48 only + (`Every_suppression_is_a_baseline_suppression_of_one_library_against_itself`, + `Every_baseline_suppression_matches_a_removed_public_api_line`, + `Every_removed_public_api_line_is_suppressed_or_declared_invisible_to_apicompat`, + `Every_invisible_change_names_a_current_removed_line_with_a_reason`, + `Suppressions_touching_client_consumed_types_are_listed_as_induced_client_breaks`, + `Every_client_consumed_type_resolves_in_the_declared_api_or_is_marked_unresolved`). +- Enums that mirror Cheat Engine constants or appear in Client signatures keep their 1.0.0 members, every enum added + since 1.0.0 is classified, and status/outcome enums start with a neutral zero member, except a pending list that can + only shrink (`Enums_mirroring_cheat_engine_constants_or_client_signatures_keep_their_1_0_0_members`, + `Every_enum_added_after_1_0_0_is_classified`, `Status_and_outcome_enums_added_after_1_0_0_do_not_default_to_success`, + `Pending_zero_value_fixes_are_still_needed`). ## Run the tests From 4860a650604ca8945f2568f3220f1eb71c3bc179 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:47:40 +0200 Subject: [PATCH 014/199] Embed an SPDX SBOM and repository metadata in the package The release must be able to attest what the package contains, and a consumer must be able to trace the package back to its commit. SBOM: src/CheatEngine.SDK references Microsoft.Sbom.Targets 4.1.13 (already pinned; a development dependency, PrivateAssets="all", so the nuspec keeps zero dependencies) and sets GenerateSBOM unconditionally, so local, fixture and CI packs carry the same inventory at _manifest/spdx_2.2/manifest.spdx.json (SPDX 2.2, the tool default) with its .sha256 sidecar. Supplier CheatEngineNet, namespace under the repository URL, license fetching off, tool verbosity Warning. The build-component scan stays on the project folder, which holds no package manifest: the SBOM describes the package and each of its 27 other entries with SHA-256/SHA-1, and lists no build-only tool as a shipped component. The tool's "no packages detected" line is expected (no NuGet dependency) and is console output, not an MSBuild warning. Microsoft.Sbom.Targets reads $(Version) at evaluation for the SBOM package version, before MinVer sets it, which produced "1.0.0"; CheatEngineSdkAlignSbomPackageVersion re-points it to $(PackageVersion) after MinVer. CESDK9008 fails a pack without GenerateSBOM or without the package reference. The nupkg is not byte-reproducible (random document namespace part, creation time, re-zip); eng/api/README.md states that reproducibility is promised for the hashed assemblies and the bridge. Repository metadata: PublishRepositoryUrl=true, recommended with the SDK-integrated Source Link (https://learn.microsoft.com/dotnet/standard/library-guidance/sourcelink), keeps in the nuspec as in 1.0.0. PackagedUmbrellaFixture gains two read-only accessors, PackagePath and Nuspec, assigned in ReadPackedNupkg (additive, for S-CI-REL's rework). SupplyChainPackageTests (collection PackagedUmbrellaSuite, trait Category=Packaging) checks the SBOM identity and sidecar, the SHA-256 of every shipped assembly and of the bridge, SBOM inventory == package entries, no repository contract file packed, the MinVer line, the .0.0.0 assembly versions, the nuspec repository commit and Source Link to raw.githubusercontent.com at that commit. The whole tests/CheatEngine.SDK.Tests project passes locally (74 tests, 1 min 24 s). Pack cost added by this lot: SBOM about 4.7 s, package validation about 0.4 s; a fixture-equivalent pack takes 11 s, far below the 3-minute PackTimeout, which stays unchanged. --- Directory.Build.targets | 25 ++ eng/api/README.md | 39 +++ src/CheatEngine.SDK/CheatEngine.SDK.csproj | 25 ++ .../Infrastructure/PackagedUmbrellaFixture.cs | 16 ++ .../Packaging/SupplyChainPackageTests.cs | 249 ++++++++++++++++++ tests/CheatEngine.SDK.Tests/README.md | 13 + 6 files changed, 367 insertions(+) create mode 100644 tests/CheatEngine.SDK.Tests/Packaging/SupplyChainPackageTests.cs diff --git a/Directory.Build.targets b/Directory.Build.targets index c9a410c5..c0467364 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -159,6 +159,31 @@ Text="$(MSBuildProjectName) $(PackageVersion) declares intentional breaks against $(PackageValidationBaselineVersion) in CompatibilitySuppressions.xml: intentional breaks against $(PackageValidationBaselineVersion) require a new major; raise MinVerMinimumMajorMinor (Directory.Build.props) or tag a new major."/> + + + + + + + + + $(PackageVersion) + + + `, as the 1.0.0 +package already did, and the embedded Portable PDB of every `lib/net10.0` assembly maps its sources to +`https://raw.githubusercontent.com/CheatEngineNet/CheatEngine.SDK//` +([Source Link](https://learn.microsoft.com/dotnet/standard/library-guidance/sourcelink)). Local packs contain local +paths in their PDBs (path mapping only runs in CI, `ContinuousIntegrationBuild`), so no test asserts their absence. + ## Repository guards | Id | Fails when | Fix | @@ -171,4 +209,5 @@ suppresses. | CESDK9004 | a project's `AnalysisLevel` differs from the pin | remove the override, or raise the pin together with `global.json` | | CESDK9006 | package validation is off, has no baseline or runs in strict mode; a CPxxxx or PKVxxx code is in `NoWarn`; or a CI pack regenerates, permits unnecessary, or bypasses suppressions or the baseline | restore the settings; regenerate the suppression file locally (integrator) | | CESDK9007 | the suppression file declares baseline breaks but the package major does not exceed the baseline major | raise `MinVerMinimumMajorMinor`, or remove the break | +| CESDK9008 | a packable project packs without `GenerateSBOM=true` or without the `Microsoft.Sbom.Targets` reference | set `GenerateSBOM` unconditionally and reference the package with `PrivateAssets="all"` | | CESDK9009 | the NuGet audit policy is weakened, a suppression is misplaced, incomplete or expired (strict run), a release is packed with a suppression, or a CI solution restore did not audit every project | restore the policy; fix or upgrade the package; complete or remove the suppression | diff --git a/src/CheatEngine.SDK/CheatEngine.SDK.csproj b/src/CheatEngine.SDK/CheatEngine.SDK.csproj index 10edd3f3..f60af5e8 100644 --- a/src/CheatEngine.SDK/CheatEngine.SDK.csproj +++ b/src/CheatEngine.SDK/CheatEngine.SDK.csproj @@ -7,6 +7,11 @@ MIT https://github.com/CheatEngineNet/CheatEngine.SDK git + + true cheat-engine;cheat-engine-plugin;sdk;lua;source-generator;roslyn-analyzer;reverse-engineering $(PackageProjectUrl)/blob/main/CHANGELOG.md true @@ -26,6 +31,26 @@ false + + + true + CheatEngineNet + https://github.com/CheatEngineNet/CheatEngine.SDK + $(MSBuildProjectDirectory) + false + + Warning + + + + + diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs index e0418a7e..d1dab398 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs @@ -87,6 +87,20 @@ public IReadOnlyList NuspecDependencyIds private set; } = []; + /// Full path of the packed .nupkg the fixture read. + public string PackagePath + { + get; + private set; + } = ""; + + /// The packed .nuspec. + public XDocument Nuspec + { + get; + private set; + } = new(); + /// Whether CESDK.CESDK exists in the default consumer's built assembly. public bool DefaultEntryPointTypeExists { @@ -730,6 +744,8 @@ private void ReadPackedNupkg(string feedDirectory) PackageVersion = fileName[(UmbrellaPackage.Id.Length + 1)..^".nupkg".Length]; (IReadOnlyList entries, XDocument nuspec) = NupkgInspector.Read(nupkgPaths[0]); + PackagePath = nupkgPaths[0]; + Nuspec = nuspec; PackageEntries = entries; NuspecDependencyIds = NupkgInspector.GetDependencyIds(nuspec); } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/SupplyChainPackageTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/SupplyChainPackageTests.cs new file mode 100644 index 00000000..72c17500 --- /dev/null +++ b/tests/CheatEngine.SDK.Tests/Packaging/SupplyChainPackageTests.cs @@ -0,0 +1,249 @@ +using System.IO.Compression; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Tests.Infrastructure; + +namespace CheatEngine.SDK.Tests.Packaging; + +/// +/// Supply-chain facts of the packed .nupkg: the embedded SPDX 2.2 SBOM describes this exact package and every +/// file in it, the nuspec and the embedded symbols point at the exact repository commit, the version follows the +/// MinVer line and the assembly version its major, and no repository contract file leaks into the package. Nothing +/// is loaded for execution: assemblies are read with System.Reflection.Metadata. +/// +[Collection(PackagedUmbrellaSuite.Name)] +[Trait("Category", "Packaging")] +public sealed partial class SupplyChainPackageTests(PackagedUmbrellaFixture fixture) +{ + private const string SbomEntry = "_manifest/spdx_2.2/manifest.spdx.json"; + private const string SbomChecksumEntry = SbomEntry + ".sha256"; + private const string RepositoryUrl = "https://github.com/CheatEngineNet/CheatEngine.SDK"; + private const string NativeBridgeEntry = "build/native/cheatengine-sdk-lua-bridge.dll"; + + /// Kind GUID of the Source Link custom debug information in a Portable PDB. + private static readonly Guid s_sourceLinkKind = new("CC110556-A091-4D38-9FEC-25AB9A351A6A"); + + private static readonly string[] s_repositoryContractFiles = + ["CompatibilitySuppressions.xml", "PublicAPI.Shipped.txt", "PublicAPI.Unshipped.txt", "packages.lock.json"]; + + [Fact] + public void Package_embeds_an_spdx_2_2_sbom_describing_itself() + { + Assert.Contains(SbomEntry, fixture.PackageEntries, StringComparer.Ordinal); + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + byte[] manifest = ReadEntry(archive, SbomEntry); + using JsonDocument sbom = JsonDocument.Parse(manifest); + JsonElement root = sbom.RootElement; + + Assert.Equal("SPDX-2.2", root.GetProperty("spdxVersion").GetString()); + string described = Assert.Single(root.GetProperty("documentDescribes").EnumerateArray()).GetString()!; + JsonElement package = Assert.Single(root.GetProperty("packages").EnumerateArray(), + p => string.Equals(p.GetProperty("SPDXID").GetString(), described, StringComparison.Ordinal)); + Assert.Equal(NuspecMetadata("id"), package.GetProperty("name").GetString()); + Assert.Equal(fixture.PackageVersion, package.GetProperty("versionInfo").GetString()); + Assert.Equal(fixture.PackageVersion, NuspecMetadata("version")); + Assert.StartsWith($"{RepositoryUrl}/", root.GetProperty("documentNamespace").GetString(), StringComparison.Ordinal); + + // The sidecar checksum the SBOM tool writes next to the manifest matches it. + string sidecar = Encoding.ASCII.GetString(ReadEntry(archive, SbomChecksumEntry)).Trim(); + Assert.Equal(Sha256(manifest), sidecar, ignoreCase: true); + } + + [Fact] + public void Sbom_lists_every_shipped_assembly_and_the_native_bridge_with_its_sha256() + { + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + Dictionary sbomSha256 = SbomFileSha256(archive); + + List shipped = [NativeBridgeEntry]; + foreach (string entry in fixture.PackageEntries) + { + if ((entry.StartsWith("lib/net10.0/", StringComparison.Ordinal) + || entry.StartsWith("analyzers/dotnet/cs/", StringComparison.Ordinal)) + && entry.EndsWith(".dll", StringComparison.Ordinal)) + { + shipped.Add(entry); + } + } + + Assert.True(shipped.Count >= 13, $"Expected the 7 libraries, 5 components and the bridge, found {shipped.Count}."); + foreach (string entry in shipped) + { + Assert.True(sbomSha256.TryGetValue(entry, out string? declared), $"The SBOM does not list '{entry}'."); + Assert.Equal(Sha256(ReadEntry(archive, entry)), declared, ignoreCase: true); + } + } + + [Fact] + public void Sbom_file_inventory_equals_the_package_entries() + { + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + SortedSet listed = new(SbomFileSha256(archive).Keys, StringComparer.Ordinal); + SortedSet packed = new(StringComparer.Ordinal); + foreach (string entry in fixture.PackageEntries) + { + if (!entry.StartsWith("_manifest/", StringComparison.Ordinal)) + { + packed.Add(entry); + } + } + + Assert.Equal(packed, listed); + } + + [Fact] + public void Package_carries_no_repository_contract_file() + { + foreach (string entry in fixture.PackageEntries) + { + string fileName = entry[(entry.LastIndexOf('/') + 1)..]; + Assert.DoesNotContain(fileName, s_repositoryContractFiles, StringComparer.OrdinalIgnoreCase); + } + } + + [Fact] + public void Package_version_is_on_the_minver_minimum_line_or_later() + { + XDocument props = XDocument.Load(RepositoryLayout.PathOf("Directory.Build.props")); + string minimum = Assert.Single(props.Descendants("MinVerMinimumMajorMinor")).Value.Trim(); + Version floor = Version.Parse(minimum); + + Version package = CoreVersion(fixture.PackageVersion); + Assert.True(new Version(package.Major, package.Minor) >= floor, + $"Package {fixture.PackageVersion} is below the MinVer line {minimum}."); + } + + [Fact] + public void Embedded_assemblies_carry_the_package_major_as_assembly_version() + { + Version expected = new(CoreVersion(fixture.PackageVersion).Major, 0, 0, 0); + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + List libraries = LibraryEntries(); + Assert.Equal(7, libraries.Count); + foreach (string entry in libraries) + { + using PEReader pe = new(new MemoryStream(ReadEntry(archive, entry))); + Version actual = pe.GetMetadataReader().GetAssemblyDefinition().Version; + Assert.True(expected == actual, $"{entry} has AssemblyVersion {actual}, expected {expected}."); + } + } + + [Fact] + public void Nuspec_names_the_repository_and_the_exact_commit() + { + XElement repository = Repository(); + Assert.Equal("git", (string?) repository.Attribute("type")); + Assert.Equal(RepositoryUrl, (string?) repository.Attribute("url")); + Assert.Matches(CommitSha(), (string?) repository.Attribute("commit") ?? ""); + } + + [Fact] + public void Embedded_libraries_carry_source_link_to_the_repository_commit() + { + string commit = (string?) Repository().Attribute("commit") ?? ""; + string expectedPrefix = $"https://raw.githubusercontent.com/CheatEngineNet/CheatEngine.SDK/{commit}/"; + using ZipArchive archive = ZipFile.OpenRead(fixture.PackagePath); + foreach (string entry in LibraryEntries()) + { + using PEReader pe = new(new MemoryStream(ReadEntry(archive, entry))); + DebugDirectoryEntry embedded = Assert.Single(pe.ReadDebugDirectory(), + static d => d.Type == DebugDirectoryEntryType.EmbeddedPortablePdb); + using MetadataReaderProvider pdbProvider = pe.ReadEmbeddedPortablePdbDebugDirectoryData(embedded); + MetadataReader pdb = pdbProvider.GetMetadataReader(); + + string? sourceLink = null; + foreach (CustomDebugInformationHandle handle in pdb.GetCustomDebugInformation(EntityHandle.ModuleDefinition)) + { + CustomDebugInformation information = pdb.GetCustomDebugInformation(handle); + if (pdb.GetGuid(information.Kind) == s_sourceLinkKind) + { + sourceLink = Encoding.UTF8.GetString(pdb.GetBlobBytes(information.Value)); + } + } + + Assert.True(sourceLink is not null, $"{entry} has no Source Link information in its embedded PDB."); + using JsonDocument map = JsonDocument.Parse(sourceLink); + List targets = []; + foreach (JsonProperty document in map.RootElement.GetProperty("documents").EnumerateObject()) + { + targets.Add(document.Value.GetString() ?? ""); + } + + Assert.NotEmpty(targets); + Assert.All(targets, target => Assert.StartsWith(expectedPrefix, target, StringComparison.Ordinal)); + } + } + + private List LibraryEntries() + { + List libraries = []; + foreach (string entry in fixture.PackageEntries) + { + if (entry.StartsWith("lib/net10.0/CheatEngine.SDK", StringComparison.Ordinal) + && entry.EndsWith(".dll", StringComparison.Ordinal)) + { + libraries.Add(entry); + } + } + + return libraries; + } + + private XElement Repository() + { + XNamespace ns = fixture.Nuspec.Root!.GetDefaultNamespace(); + return Assert.Single(fixture.Nuspec.Descendants(ns + "repository")); + } + + private string? NuspecMetadata(string name) + { + XNamespace ns = fixture.Nuspec.Root!.GetDefaultNamespace(); + return fixture.Nuspec.Root.Element(ns + "metadata")?.Element(ns + name)?.Value; + } + + /// The SBOM files array as package entry path (the ./ prefix removed) to SHA-256. + private static Dictionary SbomFileSha256(ZipArchive archive) + { + using JsonDocument sbom = JsonDocument.Parse(ReadEntry(archive, SbomEntry)); + Dictionary files = new(StringComparer.Ordinal); + foreach (JsonElement file in sbom.RootElement.GetProperty("files").EnumerateArray()) + { + string name = file.GetProperty("fileName").GetString()!; + Assert.StartsWith("./", name, StringComparison.Ordinal); + JsonElement sha256 = Assert.Single(file.GetProperty("checksums").EnumerateArray(), + static c => string.Equals(c.GetProperty("algorithm").GetString(), "SHA256", StringComparison.Ordinal)); + files.Add(name[2..], sha256.GetProperty("checksumValue").GetString()!); + } + + return files; + } + + private static byte[] ReadEntry(ZipArchive archive, string entryName) + { + ZipArchiveEntry entry = archive.GetEntry(entryName) + ?? throw new InvalidOperationException($"The package has no '{entryName}' entry."); + using Stream stream = entry.Open(); + using MemoryStream copy = new(); + stream.CopyTo(copy); + return copy.ToArray(); + } + + private static string Sha256(byte[] content) + { + return Convert.ToHexStringLower(SHA256.HashData(content)); + } + + private static Version CoreVersion(string packageVersion) + { + string core = packageVersion.Split('-', '+')[0]; + Assert.True(core.Split('.').Length == 3, $"'{packageVersion}' is not a SemVer version."); + return Version.Parse(core); + } + + [GeneratedRegex("^[0-9a-f]{40}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex CommitSha(); +} diff --git a/tests/CheatEngine.SDK.Tests/README.md b/tests/CheatEngine.SDK.Tests/README.md index ac1825ac..6eabff19 100644 --- a/tests/CheatEngine.SDK.Tests/README.md +++ b/tests/CheatEngine.SDK.Tests/README.md @@ -109,3 +109,16 @@ dotnet test --project tests/CheatEngine.SDK.Tests `NativeBridgePackagingAuditTests`; the bridge contract is described in the [bridge README](../../native/cheatengine-sdk-lua-bridge/README.md)). - Consumers build against the package packed by this run, never an earlier extraction (`RestoreIsolationTests`). +- The packed `.nupkg` embeds an SPDX 2.2 SBOM at `_manifest/spdx_2.2/manifest.spdx.json` that describes this package id + and version and lists every other entry of the package with its SHA-256, including the seven libraries, the five + Roslyn components and the native bridge (`Package_embeds_an_spdx_2_2_sbom_describing_itself`, + `Sbom_lists_every_shipped_assembly_and_the_native_bridge_with_its_sha256`, + `Sbom_file_inventory_equals_the_package_entries`). +- The nuspec names the repository and the exact 40-hex commit, and the embedded PDB of every `lib/net10.0` assembly maps + its sources to that commit through Source Link (`Nuspec_names_the_repository_and_the_exact_commit`, + `Embedded_libraries_carry_source_link_to_the_repository_commit`). +- The package version is on the `MinVerMinimumMajorMinor` line or later, every `lib/net10.0` assembly carries + `.0.0.0` as its assembly version, and no repository contract file (`CompatibilitySuppressions.xml`, PublicAPI + files, lock files) is packed (`Package_version_is_on_the_minver_minimum_line_or_later`, + `Embedded_assemblies_carry_the_package_major_as_assembly_version`, `Package_carries_no_repository_contract_file`). + The pack itself also runs package validation against the published 1.0.0 baseline, so it needs nuget.org once. From d3b00b3655c1cccd10ec7b5c5dce359a7d875842 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:50:25 +0200 Subject: [PATCH 015/199] Add the lock-file regeneration script Version and content must be locked for every project (audit ch.21 exit criteria), including the projects CI builds outside the solution, and the lock files must only ever be written by one reproducible procedure. Directory.Build.props sets RestorePackagesWithLockFile=true for every project. RestoreLockedMode is deliberately not set in MSBuild: locked mode is passed by the CI entry points, and a global locked mode would make the --force-evaluate restores of the lock check fail with NU1005: https://learn.microsoft.com/nuget/consume-packages/package-references-in-project-files#locking-dependencies https://learn.microsoft.com/nuget/reference/errors-and-warnings/nu1005 CESDK9005 (BeforeBuild, every project) fails a project that builds with RestorePackagesWithLockFile or ManagePackageVersionsCentrally other than true. With a lock file present, restore reports NU1005 first; with --no-restore the guard itself fires (both verified). eng/Update-LockFiles.ps1 [-Verify] (pwsh 7, strict mode, every dotnet and git exit code checked, comment-based help; PSScriptAnalyzer 1.25.0 reports no finding at any severity): - refuses a non-Windows host (AOT win-x64 sections record host-specific ILCompiler packages) and any SDK other than global.json's, printing the install command; - enumerates projects from git (tracked plus untracked non-ignored *.csproj) and the slnx entries, with an empty but explicit exclusion list; - restores CheatEngine.SDK.slnx, then each out-of-solution project, with --force-evaluate; gives an unchanged lock its committed bytes back and keeps the committed final-newline state of a changed one; verifies with --locked-mode; - checks the structure from evaluated properties (dotnet msbuild -getProperty): a lock per project, version 2 under CPM, no CentralTransitive in a version 1 lock, a / section per runtime identifier with runtime..Microsoft.DotNet.ILCompiler when PublishAot is true, and no CheatEngine.* package from a feed; - default mode lists the changed locks; -Verify fails, naming the files and the command, on a diff or an untracked lock. A first run generated 37 lock files in 56 s with every check green; they are committed separately. eng/api/README.md documents the procedure, the Dependabot workflow and the guard. --- CheatEngine.SDK.slnx | 1 + Directory.Build.props | 11 ++ Directory.Build.targets | 10 ++ eng/Update-LockFiles.ps1 | 342 +++++++++++++++++++++++++++++++++++++++ eng/api/README.md | 30 ++++ 5 files changed, 394 insertions(+) create mode 100644 eng/Update-LockFiles.ps1 diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index b73f6b8f..b707a144 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -35,6 +35,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 5e3f8740..7a502557 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -54,6 +54,17 @@ --> + + + true + + true diff --git a/Directory.Build.targets b/Directory.Build.targets index c0467364..1091c3a5 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -47,6 +47,16 @@ Text="$(MSBuildProjectName) needs PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt next to its project file: every shipping library tracks its public API; see eng/api/README.md."/> + + + + + - Warning From 0fcf298b5881d5620741547ca8bd37fd328b1bdc Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:12:00 +0200 Subject: [PATCH 019/199] Compile the CE 7.7 live probe with the solution LiveProbe did not compile: LiveProbeState.cs imported the test namespace CheatEngine.SDK.LiveProbe.Tests only so that its compile-linked copy in LiveProbe.Tests could find the ProbeHostGlobals stub (CS0234). Move the stub into the plugin's own namespace, LiveProbe, and drop the import, so both projects resolve the same type name. With that error gone, the remaining IDE0008 (var) findings surfaced and are fixed by dotnet format. Add the project to CheatEngine.SDK.slnx with its x64 platform mapping, exactly the entry `dotnet sln add -s tests` produces, applied as a single hunk instead of the serializer's whole-file rewrite so parallel solution edits stay mergeable. Remove its now stale exclusion from SolutionInventoryTests. CI compiles the harness from now on; nothing in CI loads or runs it. --- CheatEngine.SDK.slnx | 3 +++ .../ProbeHostGlobals.cs | 7 ++++--- tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs | 10 ++++++---- .../CheatEngine.SDK.LiveProbe.csproj | 3 ++- tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs | 1 - .../Solution/SolutionInventoryTests.cs | 4 +--- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index b707a144..f7c8ec18 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -118,6 +118,9 @@ + + + protected override void OnEnable() { - var state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); - var registration = ProbeConsole.RegisterLuaFunctions(state); + LuaState state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); + LuaStatus registration = ProbeConsole.RegisterLuaFunctions(state); HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, string.Create(CultureInfo.InvariantCulture, $"CE 7.7 live probe: console command registration -> {registration}.")); @@ -31,8 +33,8 @@ protected override void OnEnable() /// protected override void OnDisable() { - var state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); - var registration = ProbeConsole.UnregisterLuaFunctions(state); + LuaState state = CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireState(); + LuaStatus registration = ProbeConsole.UnregisterLuaFunctions(state); HostLog.Write(registration.IsOk ? HostLogLevel.Information : HostLogLevel.Error, string.Create(CultureInfo.InvariantCulture, $"CE 7.7 live probe: console command unregistration -> {registration}.")); diff --git a/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj b/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj index 3f43c335..c77a902f 100644 --- a/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj +++ b/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj @@ -1,7 +1,8 @@ - + LiveProbe x64 x64 diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs index 036896fc..413eaee8 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs @@ -6,7 +6,6 @@ using CheatEngine.SDK.Abi.Managed; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Threading; -using CheatEngine.SDK.LiveProbe.Tests; using CheatEngine.SDK.Lua.Callbacks; using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.References; diff --git a/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs index b4256501..521d8ab1 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Solution/SolutionInventoryTests.cs @@ -9,9 +9,7 @@ public sealed class SolutionInventoryTests private static readonly Dictionary s_outOfSolution = new(StringComparer.Ordinal) { ["tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj"] = - "Native AOT executable probe, restored and published on its own by the CI aot job.", - ["tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj"] = - "Manually loaded CE 7.7 evidence harness; the qualification work brings it into the solution." + "Native AOT executable probe, restored and published on its own by the CI aot job." }; [Fact] From 5876f6eb6424c3cbafb981fa649b28e490eb9566 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:21:39 +0200 Subject: [PATCH 020/199] Add Checkpoint B hooks to the CE 7.7 live probe The exact-host qualification scenarios need facts that only the loaded plugin can report, and some of them need a controlled failure. Add them to the harness, all opt-in and inert without the existing fail-closed gate: - ce77_live_probe_status() now also reports the plugin id, epoch, reported exports size (Q03) and PluginHost.LastInitRecordArgument next to the raw second bootstrap integer, still without interpretation (Q04); ce77_live_probe_status_json() returns the same facts, the assembly locations, MVIDs and Hosting load context (Q40) and the fault decisions as one ce77-live-probe-status-v1 object for the runner's driver. - ce77_live_probe_throw_managed_exception() throws inside the generated thunk so a pcall can record the catchable Lua error (Q14 at C3). - liveprobe.fault.json next to the plugin selects FactoryCreate, OnEnable or OnDisable as the stage that throws (Q06, Q08). It is read once per enable, without Lua, only when authorized; unknown content is ignored and reported. - -p:LiveProbeNonAsciiName=true builds a variant whose name mixes Latin-1 and non-cp1252 characters, to observe how CE 7.7 decodes the ANSI name (Q05.a, AnsiNameBuffer). - ce77_live_probe_pump_messages(seconds) pumps host messages from admitted main-thread work so the operator can untick the plugin during a callback; it reports the phases it saw (Q07, an observation only). The new logic lives in Lua-free files compile-linked into LiveProbe.Tests, with tests for the gate, the stage selection and the raw status values. Both READMEs move to the SDK section layout, document the hooks and point to the local qualification protocol. --- .../CheatEngine.SDK.LiveProbe.Tests.csproj | 6 + .../LiveProbeFaultInjectionTests.cs | 114 +++++++++ .../LiveProbeStatusTests.cs | 184 +++++++++++++ .../CheatEngine.SDK.LiveProbe.Tests/README.md | 52 +++- .../CE77LiveProbeBootstrap.cs | 5 + .../Ce77LiveProbePlugin.cs | 7 + .../CheatEngine.SDK.LiveProbe.csproj | 8 + .../LiveProbeFaultDecision.cs | 11 + .../LiveProbeFaultInjection.cs | 242 ++++++++++++++++++ .../LiveProbeFaultStage.cs | 17 ++ .../LiveProbeHostFacts.cs | 57 +++++ .../LiveProbeState.cs | 172 ++++++++++++- .../LiveProbeStatusReport.cs | 133 ++++++++++ .../LiveProbeStatusSnapshot.cs | 22 ++ .../CheatEngine.SDK.LiveProbe/ProbeConsole.cs | 31 +++ .../ProbePluginFactory.cs | 2 + .../ProbePluginFactoryNonAscii.cs | 18 ++ tests/CheatEngine.SDK.LiveProbe/README.md | 154 ++++++++--- 18 files changed, 1193 insertions(+), 42 deletions(-) create mode 100644 tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeFaultInjectionTests.cs create mode 100644 tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs create mode 100644 tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultDecision.cs create mode 100644 tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultInjection.cs create mode 100644 tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultStage.cs create mode 100644 tests/CheatEngine.SDK.LiveProbe/LiveProbeHostFacts.cs create mode 100644 tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusReport.cs create mode 100644 tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusSnapshot.cs create mode 100644 tests/CheatEngine.SDK.LiveProbe/ProbePluginFactoryNonAscii.cs diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj b/tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj index a3472c76..888c4999 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj @@ -16,7 +16,13 @@ + + + + + + diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeFaultInjectionTests.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeFaultInjectionTests.cs new file mode 100644 index 00000000..ed19e383 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeFaultInjectionTests.cs @@ -0,0 +1,114 @@ +using LiveProbe; + +namespace CheatEngine.SDK.LiveProbe.Tests; + +/// +/// The Checkpoint-B fault switch (liveprobe.fault.json, qualification scenarios Q06 and Q08 at C3), evaluated +/// with an injected authorization gate and an injected file reader. No host, no Lua, no file system. +/// +public sealed class LiveProbeFaultInjectionTests +{ + private const string PluginDirectory = "plugin-directory"; + + [Fact] + public void Fault_switch_is_ignored_when_authorization_is_denied() + { + bool fileRead = false; + + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate( + static () => AuthorizationDecision.Denied("The authorization manifest has expired."), + PluginDirectory, + _ => + { + fileRead = true; + return Switch("OnEnable"); + }); + + Assert.False(fileRead); + Assert.Equal(LiveProbeFaultStage.None, decision.Stage); + Assert.False(decision.FileFound); + Assert.Contains("authorization denied", decision.Reason, StringComparison.Ordinal); + Assert.Contains("The authorization manifest has expired.", decision.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData("""{"schema":"ce77-live-probe-fault-v0","throwIn":"OnEnable"}""", "unknown schema")] + [InlineData("""{"throwIn":"OnEnable"}""", "no schema")] + [InlineData("""{"schema":"ce77-live-probe-fault-v1","throwIn":"OnEnable "}""", "unknown throwIn")] + [InlineData("""{"schema":"ce77-live-probe-fault-v1","throwIn":"onenable"}""", "unknown throwIn")] + [InlineData("""{"schema":"ce77-live-probe-fault-v1"}""", "no throwIn")] + [InlineData("""["ce77-live-probe-fault-v1"]""", "no schema")] + [InlineData("""{"schema":""", "not valid JSON")] + public void Fault_switch_with_an_unknown_schema_is_ignored_and_reported(string content, string reported) + { + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate(Allowed, PluginDirectory, _ => content); + + Assert.Equal(LiveProbeFaultStage.None, decision.Stage); + Assert.True(decision.FileFound); + Assert.Contains("ignored and reported", decision.Reason, StringComparison.Ordinal); + Assert.Contains(reported, decision.Reason, StringComparison.Ordinal); + } + + [Theory] + [InlineData("None")] + [InlineData("FactoryCreate")] + [InlineData("OnEnable")] + [InlineData("OnDisable")] + public void Fault_switch_selects_exactly_the_requested_stage(string throwIn) + { + LiveProbeFaultStage requested = Enum.Parse(throwIn); + string? readPath = null; + + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate(Allowed, PluginDirectory, path => + { + readPath = path; + return Switch(requested.ToString()); + }); + + Assert.Equal(Path.Combine(PluginDirectory, LiveProbeFaultInjection.FileName), readPath); + Assert.Equal(requested, decision.Stage); + Assert.True(decision.FileFound); + foreach (LiveProbeFaultStage stage in Enum.GetValues()) + { + bool expected = stage == requested && stage != LiveProbeFaultStage.None; + Assert.Equal(expected, LiveProbeFaultInjection.ThrowsAt(decision, stage)); + } + } + + [Fact] + public void Absent_fault_file_means_no_fault() + { + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate(Allowed, PluginDirectory, + static _ => null); + + Assert.Equal(LiveProbeFaultStage.None, decision.Stage); + Assert.False(decision.FileFound); + Assert.Contains("no fault", decision.Reason, StringComparison.Ordinal); + foreach (LiveProbeFaultStage stage in Enum.GetValues()) + { + Assert.False(LiveProbeFaultInjection.ThrowsAt(decision, stage)); + } + } + + [Fact] + public void An_unreadable_fault_file_is_ignored_and_reported_without_throwing() + { + LiveProbeFaultDecision decision = LiveProbeFaultInjection.Evaluate(Allowed, PluginDirectory, + static _ => throw new IOException("Synthetic sharing violation.")); + + Assert.Equal(LiveProbeFaultStage.None, decision.Stage); + Assert.True(decision.FileFound); + Assert.Contains("IOException", decision.Reason, StringComparison.Ordinal); + } + + private static AuthorizationDecision Allowed() + { + return AuthorizationDecision.Allowed("C:\\ce.exe", "HOST", 401, "C:\\disposable-target.exe", "TARGET", + DateTimeOffset.MaxValue); + } + + private static string Switch(string throwIn) + { + return "{\"schema\":\"" + LiveProbeFaultInjection.Schema + "\",\"throwIn\":\"" + throwIn + "\"}"; + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs new file mode 100644 index 00000000..fed034eb --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs @@ -0,0 +1,184 @@ +using System.Text.Json; + +using LiveProbe; + +namespace CheatEngine.SDK.LiveProbe.Tests; + +/// +/// The status records the qualification driver reads (ce77_live_probe_status() and +/// ce77_live_probe_status_json()) and the two Checkpoint-B command hooks, with injected host facts and gates. +/// +public sealed class LiveProbeStatusTests +{ + [Fact] + public void Status_reports_the_exports_size_and_the_raw_second_bootstrap_integer_without_interpretation() + { + const int RawSecondInteger = -1_234_567; + LiveProbeState.CaptureBootstrap(0x1000, RawSecondInteger); + LiveProbeHostFacts host = Facts(RawSecondInteger, 48, 7, 3); + + string text = LiveProbeState.GetStatus(host); + using JsonDocument json = JsonDocument.Parse(LiveProbeState.GetStatusJson(host)); + + Assert.Contains("opaqueSecondInt=-1234567 (raw; no size/version meaning assigned)", text, + StringComparison.Ordinal); + Assert.Contains("pluginHostLastInitRecordArgument=-1234567", text, StringComparison.Ordinal); + Assert.Contains("pluginId=7, epoch=3, reportedExportsSize=48", text, StringComparison.Ordinal); + + JsonElement root = json.RootElement; + Assert.Equal(LiveProbeStatusReport.Schema, root.GetProperty("schema").GetString()); + JsonElement bootstrap = root.GetProperty("bootstrap"); + Assert.Equal(RawSecondInteger, bootstrap.GetProperty("opaqueSecondInt").GetInt32()); + Assert.Equal(RawSecondInteger, bootstrap.GetProperty("pluginHostLastInitRecordArgument").GetInt32()); + Assert.Equal(LiveProbeStatusReport.NoInterpretation, bootstrap.GetProperty("interpretation").GetString()); + Assert.True(bootstrap.GetProperty("calls").GetInt32() >= 1); + JsonElement context = root.GetProperty("context"); + Assert.True(context.GetProperty("present").GetBoolean()); + Assert.Equal(48, context.GetProperty("reportedExportsSize").GetInt32()); + Assert.Equal(7u, context.GetProperty("pluginId").GetUInt32()); + Assert.Equal(3, context.GetProperty("epoch").GetInt32()); + Assert.Equal("Enabled", context.GetProperty("phase").GetString()); + + // The bootstrap record never names the raw integer a size, a length or a version. + foreach (JsonProperty property in bootstrap.EnumerateObject()) + { + Assert.DoesNotContain("size", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("length", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("version", property.Name, StringComparison.OrdinalIgnoreCase); + } + + Assert.Equal(16, root.GetProperty("versionQuery").GetProperty("lastRecordSize").GetInt32()); + } + + [Fact] + public void Status_without_an_enabled_context_reports_none_instead_of_zero_values() + { + LiveProbeHostFacts host = Facts(0, 0, 0, 0) with + { + HasContext = false, + Phase = "Registered" + }; + + string text = LiveProbeState.GetStatus(host); + using JsonDocument json = JsonDocument.Parse(LiveProbeState.GetStatusJson(host)); + + Assert.Contains("context=none", text, StringComparison.Ordinal); + JsonElement context = json.RootElement.GetProperty("context"); + Assert.False(context.GetProperty("present").GetBoolean()); + Assert.False(context.TryGetProperty("reportedExportsSize", out _)); + Assert.False(context.TryGetProperty("pluginId", out _)); + Assert.Equal("Registered", context.GetProperty("phase").GetString()); + } + + [Fact] + public void Status_json_reports_the_fault_switch_decision_and_the_assembly_identities() + { + LiveProbeHostFacts host = Facts(0, 48, 1, 1); + + using JsonDocument json = JsonDocument.Parse(LiveProbeState.GetStatusJson(host)); + + JsonElement fault = json.RootElement.GetProperty("faultInjection"); + Assert.Equal(LiveProbeFaultInjection.Current.Stage.ToString(), fault.GetProperty("stage").GetString()); + Assert.Equal(JsonValueKind.Array, fault.GetProperty("injected").ValueKind); + JsonElement identity = json.RootElement.GetProperty("identity"); + Assert.Equal("plugin.dll", identity.GetProperty("pluginAssemblyLocation").GetString()); + Assert.Equal("hosting.dll", identity.GetProperty("hostingAssemblyLocation").GetString()); + Assert.Equal("Default (collectible=False)", identity.GetProperty("hostingLoadContext").GetString()); + } + + [Fact] + public void Captured_host_facts_without_a_plugin_host_report_no_context() + { + LiveProbeHostFacts host = LiveProbeHostFacts.Capture(); + + Assert.False(host.HasContext); + Assert.False(string.IsNullOrEmpty(host.PluginAssemblyMvid)); + Assert.False(string.IsNullOrEmpty(host.HostingAssemblyMvid)); + } + + [Fact] + public void Throw_hook_returns_the_denial_and_throws_nothing_without_authorization() + { + string result = LiveProbeState.ThrowManagedExceptionIfAuthorized( + static () => AuthorizationDecision.Denied("CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE is absent."), + static () => throw new InvalidOperationException("A denied authorization must not read CE's PID.")); + + Assert.Equal("Live probe denied: CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE is absent.", result); + } + + [Fact] + public void Throw_hook_throws_the_marked_exception_when_authorized_for_the_opened_target() + { + InvalidOperationException exception = Assert.Throws(() => + LiveProbeState.ThrowManagedExceptionIfAuthorized(Allowed, static () => 401)); + + Assert.Equal(LiveProbeState.ManagedExceptionMarker, exception.Message); + using JsonDocument json = JsonDocument.Parse(LiveProbeState.GetStatusJson(Facts(0, 48, 1, 1))); + Assert.True(json.RootElement.GetProperty("managedExceptionThrows").GetInt32() >= 1); + } + + [Theory] + [InlineData(0.5)] + [InlineData(61)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void Pump_hook_refuses_a_duration_outside_one_to_sixty_seconds_before_any_host_call(double seconds) + { + bool pumped = false; + + string result = LiveProbeState.PumpMessages( + static () => throw new InvalidOperationException("An invalid duration must not evaluate authorization."), + static () => throw new InvalidOperationException("An invalid duration must not read CE's PID."), + seconds, + _ => + { + pumped = true; + return "pumped"; + }); + + Assert.False(pumped); + Assert.StartsWith("Pump refused:", result, StringComparison.Ordinal); + } + + [Fact] + public void Pump_hook_is_inert_without_authorization_and_pumps_only_for_the_opened_target() + { + double pumpedFor = 0; + + string denied = LiveProbeState.PumpMessages(static () => AuthorizationDecision.Denied("No manifest."), + static () => 401, 5, seconds => + { + pumpedFor = seconds; + return "pumped"; + }); + string wrongTarget = LiveProbeState.PumpMessages(Allowed, static () => 402, 5, seconds => + { + pumpedFor = seconds; + return "pumped"; + }); + string allowed = LiveProbeState.PumpMessages(Allowed, static () => 401, 5, seconds => + { + pumpedFor = seconds; + return "pumped"; + }); + + Assert.Equal("Live probe denied: No manifest.", denied); + Assert.Equal("Live probe denied: CE reports opened process 402, not manifest process 401.", wrongTarget); + Assert.Equal("pumped", allowed); + Assert.Equal(5, pumpedFor); + } + + private static LiveProbeHostFacts Facts(int lastInitRecordArgument, int reportedExportsSize, uint pluginId, + int epoch) + { + return new LiveProbeHostFacts(true, pluginId, epoch, reportedExportsSize, true, true, "Enabled", + lastInitRecordArgument, 16, "plugin.dll", "00000000-0000-0000-0000-000000000001", "hosting.dll", + "00000000-0000-0000-0000-000000000002", "Default (collectible=False)"); + } + + private static AuthorizationDecision Allowed() + { + return AuthorizationDecision.Allowed("C:\\ce.exe", "HOST", 401, "C:\\disposable-target.exe", "TARGET", + DateTimeOffset.MaxValue); + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/README.md b/tests/CheatEngine.SDK.LiveProbe.Tests/README.md index e95d129f..a9d8a384 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/README.md +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/README.md @@ -1,6 +1,50 @@ # CheatEngine.SDK.LiveProbe.Tests -Deterministic unit tests for the manually loaded CE 7.7 LiveProbe evidence harness. The project compiles the four -evidence-only sources under test directly and supplies a local stub for the generated CE Lua global, so it does not load -the plugin or invoke its source generator. Tests inject only in-process authorization/PID and file-open results; they -never start Cheat Engine, load a CE host, select a target, or produce a live qualification artifact. +Deterministic unit tests for the manually loaded CE 7.7 [LiveProbe](../CheatEngine.SDK.LiveProbe/README.md) evidence +harness. + +## Objective + +Prove the fail-closed behaviour of the harness without a Cheat Engine host: the authorization gate is re-evaluated +before every acting command, the Checkpoint B hooks are inert without it, the fault switch selects exactly one stage, +and the status records report raw host facts without interpreting them. + +## Why it exists + +The harness is only ever run inside Cheat Engine, where a regression would surface as a wrong or missing qualification +record. These tests catch it in CI instead. They never start Cheat Engine, load a CE host, select a target, or produce +a qualification artifact: a green run here is C1 evidence about the harness, never host evidence. + +## How it works + +The project compiles the Lua-free sources of the harness directly (`Compile Include` links in the project file) and +supplies a local stub for the generated `ProbeHostGlobals.GetOpenedProcessId` Lua global, in the same `LiveProbe` +namespace, so it neither loads the plugin nor runs its Lua source generator. Tests inject the authorization decision, +CE's opened PID, the fault-switch file reader and the `PluginHost` facts. `AssemblyInfo.cs` disables parallelization +because the harness keeps process-wide static state. + +| Test class | Covers | +|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| `LiveProbeStateTests` | Fresh authorization and target-PID checks before host-profile capture and every protected command. | +| `LiveProbeStatusTests` | Text and JSON status (plugin id, epoch, exports size, raw second bootstrap integer), the exception hook and the pump hook. | +| `LiveProbeFaultInjectionTests` | The `liveprobe.fault.json` switch: never read without authorization, exact stage selection, ignored and reported failures. | +| `HostProfileObservationTests` | Typed outcomes for missing, locked, vanishing or protected identity files. | + +## Promise + +- A capture or protected command after the manifest expired, the target image changed or CE selected another PID + returns a fresh denial and never runs the probe (`LiveProbeStateTests`). +- The status reports the exports size and the raw second bootstrap integer without naming it a size, length or + version + (`LiveProbeStatusTests.Status_reports_the_exports_size_and_the_raw_second_bootstrap_integer_without_interpretation`). +- The exception and pump hooks do nothing without authorization, and the pump refuses an out-of-range duration before + any host call (`LiveProbeStatusTests`). +- The fault switch is ignored without authorization, selects exactly the requested stage, and treats an absent file as + no fault (`LiveProbeFaultInjectionTests`). +- Identity-file failures are reported as typed outcomes (`HostProfileObservationTests`). + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj +``` diff --git a/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs b/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs index 0ac29503..e3543f50 100644 --- a/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs +++ b/tests/CheatEngine.SDK.LiveProbe/CE77LiveProbeBootstrap.cs @@ -30,7 +30,12 @@ public static int CEPluginInitialize(nint initRecord, int opaqueHostArgument) try { LiveProbeState.CaptureBootstrap(initRecord, opaqueHostArgument); +#if LIVEPROBE_NON_ASCII_NAME + int result = + PluginHost.InitializeManaged(initRecord, opaqueHostArgument); +#else int result = PluginHost.InitializeManaged(initRecord, opaqueHostArgument); +#endif LiveProbeState.TryWriteTailCanaryAfterPackedRecord(initRecord, result); return result; } diff --git a/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs b/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs index 9873dbb9..b3e0f750 100644 --- a/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs +++ b/tests/CheatEngine.SDK.LiveProbe/Ce77LiveProbePlugin.cs @@ -28,6 +28,10 @@ protected override void OnEnable() LiveProbeState.ValidateAfterEnable(); HostLog.Write(HostLogLevel.Information, LiveProbeState.GetStatus()); + + // Checkpoint B, Q06: an authorized liveprobe.fault.json can make this enable fail after the console commands + // were registered, so the SDK's cleanup of a failed enable is observable. + LiveProbeFaultInjection.EnterOnEnable(); } /// @@ -42,5 +46,8 @@ protected override void OnDisable() // Deliberately do not dispose the callback-shutdown probe here. LuaRuntime.Detach, which runs immediately after // OnDisable, is the system under test: it must neutralize the callback before freeing its GCHandle. LiveProbeState.RecordDisable(); + + // Checkpoint B, Q08: an authorized liveprobe.fault.json can make OnDisable throw after its own cleanup. + LiveProbeFaultInjection.EnterOnDisable(); } } diff --git a/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj b/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj index c77a902f..acae138c 100644 --- a/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj +++ b/tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj @@ -13,6 +13,14 @@ $(ArtifactsPath)\obj\$(MSBuildProjectName)\generated\$(Configuration) + + + $(DefineConstants);LIVEPROBE_NON_ASCII_NAME + + diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultDecision.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultDecision.cs new file mode 100644 index 00000000..214610b3 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultDecision.cs @@ -0,0 +1,11 @@ +namespace LiveProbe; + +/// One evaluation of the fault-injection switch. +/// The stage that throws during this enable; when ignored. +/// Why this stage was selected or why the switch was ignored, for the log and the status record. +/// Whether a switch file existed next to the plugin and was read. +internal readonly record struct LiveProbeFaultDecision(LiveProbeFaultStage Stage, string Reason, bool FileFound) +{ + internal static LiveProbeFaultDecision NotEvaluated => + new(LiveProbeFaultStage.None, "Not evaluated: no enable has run.", false); +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultInjection.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultInjection.cs new file mode 100644 index 00000000..2b458941 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultInjection.cs @@ -0,0 +1,242 @@ +using System.Globalization; +using System.Text.Json; + +using CheatEngine.SDK.Hosting.Diagnostics; + +namespace LiveProbe; + +/// +/// The Checkpoint-B fault-injection switch (qualification scenarios Q06 and Q08 at C3). A JSON file named +/// next to the plugin assembly selects one lifecycle stage that throws a managed exception: +/// {"schema":"ce77-live-probe-fault-v1","throwIn":"None|FactoryCreate|OnEnable|OnDisable"}. +/// +/// +/// The switch is read once per enable, without Lua, and only when +/// allows: without the operator's short-lived authorization the file is never opened. Every decision, including an +/// ignored file, is logged and kept for ce77_live_probe_status_json(). This is a test-harness mechanism only; +/// the SDK has no such switch. +/// +internal static class LiveProbeFaultInjection +{ + internal const string FileName = "liveprobe.fault.json"; + internal const string Schema = "ce77-live-probe-fault-v1"; + internal const string InjectedFaultMessagePrefix = "CE 7.7 live probe injected fault at "; + + private const int MaximumFileBytes = 4096; + private static readonly Lock Gate = new(); + private static LiveProbeFaultDecision s_current = LiveProbeFaultDecision.NotEvaluated; + private static bool s_evaluatedForPendingEnable; + private static int s_enableSequence; + private static readonly List s_injected = []; + + /// Gets the decision of the most recent enable. + internal static LiveProbeFaultDecision Current + { + get + { + lock (Gate) + { + return s_current; + } + } + } + + /// Gets the stages that actually threw, in order, as stage@enable entries. + internal static IReadOnlyList InjectedFaults + { + get + { + lock (Gate) + { + return [.. s_injected]; + } + } + } + + /// + /// Evaluates the switch. Pure apart from the two injected delegates: the authorization evaluator and the file + /// reader (which returns when the file does not exist). + /// + /// The live-probe authorization gate; the file is read only when it allows. + /// The directory that holds the plugin assembly. + /// Reads a whole file as UTF-8 text, or returns when it is absent. + /// The decision; never throws for a missing, unreadable or malformed file. + internal static LiveProbeFaultDecision Evaluate(Func evaluateAuthorization, + string pluginDirectory, Func readFile) + { + AuthorizationDecision authorization = evaluateAuthorization(); + if (!authorization.IsAllowed) + { + return new LiveProbeFaultDecision(LiveProbeFaultStage.None, + "Fault switch ignored without reading it: live-probe authorization denied (" + authorization.Reason + + ").", false); + } + + string path = Path.Combine(pluginDirectory, FileName); + string? text; + try + { + text = readFile(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + return new LiveProbeFaultDecision(LiveProbeFaultStage.None, + "Fault switch ignored: " + FileName + " could not be read (" + exception.GetType().Name + ").", true); + } + + if (text is null) + { + return new LiveProbeFaultDecision(LiveProbeFaultStage.None, + "No " + FileName + " next to the plugin: no fault.", false); + } + + return Parse(text); + } + + /// Returns whether throws at . + internal static bool ThrowsAt(LiveProbeFaultDecision decision, LiveProbeFaultStage stage) + { + return stage is not LiveProbeFaultStage.None && decision.Stage == stage; + } + + /// + /// Production hook of IPluginFactory.Create: the first point of an enable that constructs the plugin. + /// Evaluates the switch for this enable and throws when it selects . + /// + internal static void EnterFactoryCreate() + { + LiveProbeFaultDecision decision = EvaluateForEnable(); + lock (Gate) + { + s_evaluatedForPendingEnable = true; + } + + ThrowIfSelected(decision, LiveProbeFaultStage.FactoryCreate); + } + + /// + /// Production hook of OnEnable. Reuses the decision the factory made during this same enable, otherwise + /// evaluates the switch, then throws when it selects . + /// + internal static void EnterOnEnable() + { + LiveProbeFaultDecision decision; + bool reuse; + lock (Gate) + { + reuse = s_evaluatedForPendingEnable; + s_evaluatedForPendingEnable = false; + decision = s_current; + } + + if (!reuse) + { + decision = EvaluateForEnable(); + } + + ThrowIfSelected(decision, LiveProbeFaultStage.OnEnable); + } + + /// Production hook of OnDisable: throws when this enable's decision selects that stage. + internal static void EnterOnDisable() + { + ThrowIfSelected(Current, LiveProbeFaultStage.OnDisable); + } + + private static LiveProbeFaultDecision EvaluateForEnable() + { + string pluginDirectory = Path.GetDirectoryName(typeof(LiveProbeFaultInjection).Assembly.Location) ?? + AppContext.BaseDirectory; + LiveProbeFaultDecision decision = Evaluate(LiveProbeAuthorization.Evaluate, pluginDirectory, ReadIfPresent); + int sequence; + lock (Gate) + { + sequence = ++s_enableSequence; + s_current = decision; + } + + HostLog.Write(decision.Stage == LiveProbeFaultStage.None ? HostLogLevel.Information : HostLogLevel.Warning, + string.Create(CultureInfo.InvariantCulture, + $"CE 7.7 live probe: fault switch for enable #{sequence} -> {decision.Stage}. {decision.Reason}")); + return decision; + } + + private static void ThrowIfSelected(LiveProbeFaultDecision decision, LiveProbeFaultStage stage) + { + if (!ThrowsAt(decision, stage)) + { + return; + } + + lock (Gate) + { + s_injected.Add(string.Create(CultureInfo.InvariantCulture, $"{stage}@{s_enableSequence}")); + } + + throw new InvalidOperationException(InjectedFaultMessagePrefix + stage + " (" + FileName + ")."); + } + + private static LiveProbeFaultDecision Parse(string text) + { + try + { + using JsonDocument document = JsonDocument.Parse(text); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("schema", out JsonElement schema) || schema.ValueKind != JsonValueKind.String) + { + return Ignored("it has no schema string"); + } + + if (!string.Equals(schema.GetString(), Schema, StringComparison.Ordinal)) + { + return Ignored("unknown schema '" + schema.GetString() + "', expected '" + Schema + "'"); + } + + if (!root.TryGetProperty("throwIn", out JsonElement throwIn) || throwIn.ValueKind != JsonValueKind.String) + { + return Ignored("it has no throwIn string"); + } + + string requested = throwIn.GetString() ?? string.Empty; + foreach (LiveProbeFaultStage stage in Enum.GetValues()) + { + if (string.Equals(stage.ToString(), requested, StringComparison.Ordinal)) + { + return new LiveProbeFaultDecision(stage, + "Fault switch selects " + stage + " (" + FileName + ", schema " + Schema + ").", true); + } + } + + return Ignored("unknown throwIn '" + requested + "'"); + } + catch (JsonException exception) + { + return Ignored("it is not valid JSON (" + exception.GetType().Name + ")"); + } + } + + private static LiveProbeFaultDecision Ignored(string why) + { + return new LiveProbeFaultDecision(LiveProbeFaultStage.None, + "Fault switch ignored and reported: " + FileName + " " + why + ".", true); + } + + private static string? ReadIfPresent(string path) + { + FileInfo file = new(path); + if (!file.Exists) + { + return null; + } + + if (file.Length > MaximumFileBytes) + { + throw new IOException("The fault switch is larger than " + + MaximumFileBytes.ToString(CultureInfo.InvariantCulture) + " bytes."); + } + + return File.ReadAllText(path); + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultStage.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultStage.cs new file mode 100644 index 00000000..b0dc3d52 --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeFaultStage.cs @@ -0,0 +1,17 @@ +namespace LiveProbe; + +/// The lifecycle stage at which the fault-injection switch throws, if any. +internal enum LiveProbeFaultStage +{ + /// No fault: the switch is absent, ignored or explicitly None. + None, + + /// The plugin factory throws before it constructs the plugin (the enable fails and is retried later). + FactoryCreate, + + /// OnEnable throws after the console commands are registered. + OnEnable, + + /// OnDisable throws after the console commands are unregistered. + OnDisable +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeHostFacts.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeHostFacts.cs new file mode 100644 index 00000000..d1af6dbb --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeHostFacts.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using System.Reflection; +using System.Runtime.Loader; + +using CheatEngine.SDK.Hosting.Bootstrap; +using CheatEngine.SDK.Hosting.Context; + +namespace LiveProbe; + +/// +/// Facts the SDK host recorded about this process, read without Lua. Qualification scenarios Q03 (observed exports +/// size), Q04 (raw second bootstrap integer), Q05 (plugin id and epoch) and Q40 (where the assemblies were loaded +/// from) record them from ce77_live_probe_status_json(). +/// +internal readonly record struct LiveProbeHostFacts( + bool HasContext, + uint PluginId, + int Epoch, + int ReportedExportsSize, + bool HasProcessMessages, + bool HasCheckSynchronize, + string Phase, + int LastInitRecordArgument, + int LastVersionRecordSize, + string PluginAssemblyLocation, + string PluginAssemblyMvid, + string HostingAssemblyLocation, + string HostingAssemblyMvid, + string HostingLoadContext) +{ + /// Reads the current facts from and the loaded assemblies. + internal static LiveProbeHostFacts Capture() + { + PluginContext? context = PluginHost.Context; + Assembly plugin = typeof(LiveProbeHostFacts).Assembly; + Assembly hosting = typeof(PluginHost).Assembly; + AssemblyLoadContext? hostingLoadContext = AssemblyLoadContext.GetLoadContext(hosting); + return new LiveProbeHostFacts( + context is not null, + context?.PluginId ?? 0, + context?.Epoch ?? 0, + context?.ReportedExportsSize ?? 0, + context?.HasProcessMessages ?? false, + context?.HasCheckSynchronize ?? false, + PluginHost.Phase.ToString(), + PluginHost.LastInitRecordArgument, + PluginHost.LastVersionRecordSize, + plugin.Location, + plugin.ManifestModule.ModuleVersionId.ToString("D", CultureInfo.InvariantCulture), + hosting.Location, + hosting.ManifestModule.ModuleVersionId.ToString("D", CultureInfo.InvariantCulture), + hostingLoadContext is null + ? "" + : string.Create(CultureInfo.InvariantCulture, + $"{hostingLoadContext.Name ?? ""} (collectible={hostingLoadContext.IsCollectible})")); + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs index 413eaee8..72868b02 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs @@ -1,9 +1,12 @@ +using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json; using CheatEngine.SDK.Abi.Managed; +using CheatEngine.SDK.Hosting.Bootstrap; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Threading; using CheatEngine.SDK.Lua.Callbacks; @@ -19,6 +22,12 @@ namespace LiveProbe; internal static unsafe class LiveProbeState { private const uint TailCanary = 0x7A_51_CE_77U; + private const int MinimumPumpSeconds = 1; + private const int MaximumPumpSeconds = 60; + + /// The message of the exception ce77_live_probe_throw_managed_exception() throws (Q14 at C3). + internal const string ManagedExceptionMarker = "CE 7.7 live probe deliberate managed exception (Q14)."; + private static readonly Lock Gate = new(); private static BootstrapObservation s_bootstrap; private static AuthorizationDecision s_bootstrapAuthorization = AuthorizationDecision.Denied("Not evaluated."); @@ -32,6 +41,7 @@ internal static unsafe class LiveProbeState private static LuaCallback? s_callback; private static CallbackCounter? s_callbackCounter; private static int s_luaProbeSerial; + private static int s_managedExceptionThrows; internal static void CaptureBootstrap(nint initRecord, int opaqueHostArgument) { @@ -170,6 +180,12 @@ internal static void RecordDisable() } internal static string GetStatus() + { + return GetStatus(LiveProbeHostFacts.Capture()); + } + + // The facts parameter is the unit-test seam: production always captures them from PluginHost immediately. + internal static string GetStatus(LiveProbeHostFacts host) { lock (Gate) { @@ -179,7 +195,21 @@ internal static string GetStatus() .Append(s_bootstrap.InitRecord.ToString("X", CultureInfo.InvariantCulture)) .Append(", opaqueSecondInt=").Append(s_bootstrap.OpaqueArgument) .Append(" (raw; no size/version meaning assigned)") - .Append(", tailCanaryWritten=").Append(s_bootstrap.TailCanaryWritten) + .Append(", pluginHostLastInitRecordArgument=").Append(host.LastInitRecordArgument) + .Append(", phase=").Append(host.Phase); + + if (host.HasContext) + { + builder.Append(", pluginId=").Append(host.PluginId) + .Append(", epoch=").Append(host.Epoch) + .Append(", reportedExportsSize=").Append(host.ReportedExportsSize); + } + else + { + builder.Append(", context=none"); + } + + builder.Append(", tailCanaryWritten=").Append(s_bootstrap.TailCanaryWritten) .Append(", tailWrites=").Append(s_bootstrap.TailWriteCount); if (s_bootstrap.TailCanaryWritten) @@ -213,6 +243,95 @@ internal static string GetStatus() } } + internal static string GetStatusJson() + { + return GetStatusJson(LiveProbeHostFacts.Capture()); + } + + // One JSON object for the qualification driver (schema ce77-live-probe-status-v1). Like the text status it is a + // read of process-local facts: it neither touches a target nor calls Lua, so it is not authorization-gated. + internal static string GetStatusJson(LiveProbeHostFacts host) + { + LiveProbeStatusSnapshot snapshot; + lock (Gate) + { + snapshot = new LiveProbeStatusSnapshot( + host, + s_bootstrap.Calls, + s_bootstrap.OpaqueArgument, + s_bootstrap.TailCanaryWritten, + s_bootstrap.TailWriteCount, + s_bootstrap.TailReadBeforeWrite, + s_bootstrap.TailFailure, + s_bootstrapAuthorization.IsAllowed, + s_bootstrapAuthorization.Reason, + IsRuntimeProbeAllowedUnsafe(), + s_targetMatchFailure, + LiveProbeFaultInjection.Current, + LiveProbeFaultInjection.InjectedFaults, + s_managedExceptionThrows, + s_synchronize.ToDisplayString(), + s_luaThread.ToDisplayString(), + s_reset.ToDisplayString(), + s_callback is null + ? "not prepared" + : string.Create(CultureInfo.InvariantCulture, + $"prepared=true, released={s_callback.IsReleased}, managedCalls={s_callbackCounter?.Calls ?? 0}")); + } + + return LiveProbeStatusReport.ToJson(snapshot); + } + + internal static string ThrowManagedExceptionIfAuthorized() + { + return ThrowManagedExceptionIfAuthorized(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId); + } + + // Q14 at C3: the generated [LuaFunction] thunk must turn this exception into a Lua error that the driver's pcall + // catches, and the next call on the same state must still work. Without a fresh authorization it returns the + // denial and throws nothing. + internal static string ThrowManagedExceptionIfAuthorized(Func evaluateAuthorization, + Func getOpenedProcessId) + { + if (!TryRequireRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId, out string denied)) + { + return denied; + } + + lock (Gate) + { + s_managedExceptionThrows++; + } + + throw new InvalidOperationException(ManagedExceptionMarker); + } + + internal static string PumpMessages(double seconds) + { + return PumpMessages(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId, seconds, + PumpInsideAdmittedWork); + } + + // Q07 at C3, an observation rather than a promise: the pump runs as admitted main-thread work so that the operator + // can untick the plugin in Cheat Engine while this callback is still running. The SDK is expected to refuse that + // nested disable without waiting for itself; the returned record lists the lifecycle phases seen between pumps. + internal static string PumpMessages(Func evaluateAuthorization, + Func getOpenedProcessId, double seconds, Func pump) + { + if (!double.IsFinite(seconds) || seconds < MinimumPumpSeconds || seconds > MaximumPumpSeconds) + { + return string.Create(CultureInfo.InvariantCulture, + $"Pump refused: the duration must be between {MinimumPumpSeconds} and {MaximumPumpSeconds} seconds."); + } + + if (!TryRequireRuntimeAuthorization(evaluateAuthorization, getOpenedProcessId, out string denied)) + { + return denied; + } + + return pump(seconds); + } + internal static string CaptureHostProfile() { return CaptureHostProfile(LiveProbeAuthorization.Evaluate, ProbeHostGlobals.GetOpenedProcessId, @@ -450,6 +569,57 @@ internal static string PrepareCallbackShutdownProbe() "Callback prepared. First run pcall(ce77_live_probe_callback_shutdown) once (it returns a count). Then disable this plugin in CE, run pcall(ce77_live_probe_callback_shutdown) again, and preserve the raw pcall result. Re-enable and call ce77_live_probe_status()."; } + private static string PumpInsideAdmittedWork(double seconds) + { + return MainThread.Invoke(static duration => PumpAndObservePhases(duration), seconds); + } + + private static string PumpAndObservePhases(double seconds) + { + PluginHostLifecyclePhase before = PluginHost.Phase; + PluginHostLifecyclePhase last = before; + List sequence = [before.ToString()]; + int pumps = 0; + TimeSpan limit = TimeSpan.FromSeconds(seconds); + Stopwatch elapsed = Stopwatch.StartNew(); + while (elapsed.Elapsed < limit) + { + MainThread.ProcessMessages(); + pumps++; + PluginHostLifecyclePhase now = PluginHost.Phase; + if (now != last) + { + sequence.Add(now.ToString()); + last = now; + } + + Thread.Sleep(20); + } + + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + writer.WriteString("schema", "ce77-live-probe-pump-v1"); + writer.WriteNumber("requestedSeconds", seconds); + writer.WriteNumber("elapsedMs", (long) elapsed.Elapsed.TotalMilliseconds); + writer.WriteNumber("pumps", pumps); + writer.WriteString("phaseBefore", before.ToString()); + writer.WriteString("phaseAfter", PluginHost.Phase.ToString()); + writer.WriteStartArray("phaseSequence"); + foreach (string phase in sequence) + { + writer.WriteStringValue(phase); + } + + writer.WriteEndArray(); + writer.WriteBoolean("enabledAfter", PluginHost.IsEnabled); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + private static void RunSynchronizeProbe() { SynchronizeObservation observation = SynchronizeObservation.Started; diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusReport.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusReport.cs new file mode 100644 index 00000000..96cfbfea --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusReport.cs @@ -0,0 +1,133 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace LiveProbe; + +/// +/// Serializes the status record as one JSON object (schema ). The qualification driver stores the +/// text verbatim in its event log; the runner parses it. Nothing here interprets the second bootstrap integer. +/// +internal static class LiveProbeStatusReport +{ + internal const string Schema = "ce77-live-probe-status-v1"; + + /// The value of bootstrap.interpretation: the raw integer is never given a meaning here. + internal const string NoInterpretation = "none"; + + internal static string ToJson(in LiveProbeStatusSnapshot snapshot) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + writer.WriteString("schema", Schema); + WriteBootstrap(writer, snapshot); + + writer.WriteStartObject("versionQuery"); + writer.WriteNumber("lastRecordSize", snapshot.Host.LastVersionRecordSize); + writer.WriteEndObject(); + + WriteContext(writer, snapshot.Host); + WriteIdentity(writer, snapshot.Host); + WriteGates(writer, snapshot); + WriteFaultInjection(writer, snapshot); + writer.WriteNumber("managedExceptionThrows", snapshot.ManagedExceptionThrows); + + writer.WriteStartObject("observations"); + writer.WriteString("synchronize", snapshot.Synchronize); + writer.WriteString("luaThreads", snapshot.LuaThreads); + writer.WriteString("reset", snapshot.Reset); + writer.WriteString("callback", snapshot.Callback); + writer.WriteEndObject(); + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteBootstrap(Utf8JsonWriter writer, in LiveProbeStatusSnapshot snapshot) + { + writer.WriteStartObject("bootstrap"); + writer.WriteNumber("calls", snapshot.BootstrapCalls); + writer.WriteNumber("opaqueSecondInt", snapshot.OpaqueSecondInt); + writer.WriteNumber("pluginHostLastInitRecordArgument", snapshot.Host.LastInitRecordArgument); + writer.WriteString("interpretation", NoInterpretation); + writer.WriteBoolean("tailCanaryWritten", snapshot.TailCanaryWritten); + writer.WriteNumber("tailWrites", snapshot.TailWrites); + if (snapshot.TailCanaryWritten) + { + writer.WriteString("tailBeforeHex", snapshot.TailReadBeforeWrite.ToString("X8", CultureInfo.InvariantCulture)); + } + else + { + writer.WriteNull("tailBeforeHex"); + } + + if (snapshot.TailFailure is null) + { + writer.WriteNull("tailFailure"); + } + else + { + writer.WriteString("tailFailure", snapshot.TailFailure); + } + + writer.WriteEndObject(); + } + + private static void WriteContext(Utf8JsonWriter writer, in LiveProbeHostFacts host) + { + writer.WriteStartObject("context"); + writer.WriteBoolean("present", host.HasContext); + if (host.HasContext) + { + writer.WriteNumber("pluginId", host.PluginId); + writer.WriteNumber("epoch", host.Epoch); + writer.WriteNumber("reportedExportsSize", host.ReportedExportsSize); + writer.WriteBoolean("hasProcessMessages", host.HasProcessMessages); + writer.WriteBoolean("hasCheckSynchronize", host.HasCheckSynchronize); + } + + writer.WriteString("phase", host.Phase); + writer.WriteEndObject(); + } + + private static void WriteIdentity(Utf8JsonWriter writer, in LiveProbeHostFacts host) + { + writer.WriteStartObject("identity"); + writer.WriteString("pluginAssemblyLocation", host.PluginAssemblyLocation); + writer.WriteString("pluginAssemblyMvid", host.PluginAssemblyMvid); + writer.WriteString("hostingAssemblyLocation", host.HostingAssemblyLocation); + writer.WriteString("hostingAssemblyMvid", host.HostingAssemblyMvid); + writer.WriteString("hostingLoadContext", host.HostingLoadContext); + writer.WriteEndObject(); + } + + private static void WriteGates(Utf8JsonWriter writer, in LiveProbeStatusSnapshot snapshot) + { + writer.WriteStartObject("gates"); + writer.WriteBoolean("bootstrapAllowed", snapshot.BootstrapGateAllowed); + writer.WriteString("bootstrapReason", snapshot.BootstrapGateReason); + writer.WriteBoolean("runtimeAllowed", snapshot.RuntimeGateAllowed); + writer.WriteString("runtimeReason", snapshot.RuntimeGateReason); + writer.WriteEndObject(); + } + + private static void WriteFaultInjection(Utf8JsonWriter writer, in LiveProbeStatusSnapshot snapshot) + { + writer.WriteStartObject("faultInjection"); + writer.WriteString("stage", snapshot.Fault.Stage.ToString()); + writer.WriteString("reason", snapshot.Fault.Reason); + writer.WriteBoolean("fileFound", snapshot.Fault.FileFound); + writer.WriteStartArray("injected"); + foreach (string injected in snapshot.InjectedFaults) + { + writer.WriteStringValue(injected); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusSnapshot.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusSnapshot.cs new file mode 100644 index 00000000..49c640cf --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeStatusSnapshot.cs @@ -0,0 +1,22 @@ +namespace LiveProbe; + +/// Everything ce77_live_probe_status_json() reports, captured under the probe's lock. +internal readonly record struct LiveProbeStatusSnapshot( + LiveProbeHostFacts Host, + int BootstrapCalls, + int OpaqueSecondInt, + bool TailCanaryWritten, + int TailWrites, + uint TailReadBeforeWrite, + string? TailFailure, + bool BootstrapGateAllowed, + string BootstrapGateReason, + bool RuntimeGateAllowed, + string RuntimeGateReason, + LiveProbeFaultDecision Fault, + IReadOnlyList InjectedFaults, + int ManagedExceptionThrows, + string Synchronize, + string LuaThreads, + string Reset, + string Callback); diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs b/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs index f81fb0ad..76a0b596 100644 --- a/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs +++ b/tests/CheatEngine.SDK.LiveProbe/ProbeConsole.cs @@ -15,6 +15,37 @@ public static string Status() return LiveProbeState.GetStatus(); } + /// + /// Returns the same observations as as one JSON object (schema + /// ce77-live-probe-status-v1) for the qualification driver: plugin id, epoch, reported exports size, the raw + /// second bootstrap integer, assembly identities and the fault-switch decision. + /// + [LuaFunction("ce77_live_probe_status_json")] + public static string StatusJson() + { + return LiveProbeState.GetStatusJson(); + } + + /// + /// Authorized only: throws a managed exception inside the generated thunk so that a pcall can record the + /// resulting Lua error (qualification scenario Q14 at C3). Returns the denial text when not authorized. + /// + [LuaFunction("ce77_live_probe_throw_managed_exception")] + public static string ThrowManagedException() + { + return LiveProbeState.ThrowManagedExceptionIfAuthorized(); + } + + /// + /// Authorized only: pumps Cheat Engine's messages for 1 to 60 seconds from admitted main-thread work, so that an + /// operator can untick the plugin while this callback runs (Q07 at C3). Returns a JSON phase record. + /// + [LuaFunction("ce77_live_probe_pump_messages")] + public static string PumpMessages(double seconds) + { + return LiveProbeState.PumpMessages(seconds); + } + /// Captures a JSON identity record for the authorized CE host, Lua, bridge, plugin, and disposable target. [LuaFunction("ce77_live_probe_host_profile")] public static string HostProfile() diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs index 8a7b14e3..a0a4e8be 100644 --- a/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs +++ b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactory.cs @@ -10,6 +10,8 @@ internal sealed class ProbePluginFactory : IPluginFactory public static CheatEnginePlugin Create() { + // Checkpoint B, Q06: an authorized liveprobe.fault.json can make construction fail for this enable. + LiveProbeFaultInjection.EnterFactoryCreate(); return new Ce77LiveProbePlugin(); } } diff --git a/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactoryNonAscii.cs b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactoryNonAscii.cs new file mode 100644 index 00000000..073643fb --- /dev/null +++ b/tests/CheatEngine.SDK.LiveProbe/ProbePluginFactoryNonAscii.cs @@ -0,0 +1,18 @@ +#if LIVEPROBE_NON_ASCII_NAME +using CheatEngine.SDK.Hosting.Plugin; + +namespace LiveProbe; + +// Built only with -p:LiveProbeNonAsciiName=true (Checkpoint B, Q05.a). The name mixes one Latin-1 character and two +// characters outside code page 1252, so the run shows how Cheat Engine 7.7 decodes the name the SDK converts to the +// process ANSI code page (libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs). +internal sealed class ProbePluginFactoryNonAscii : IPluginFactory +{ + public static ReadOnlySpan Utf8Name => "CheatEngine.SDK Live Probe \u00e9 \u65e5\u672c"u8; + + public static CheatEnginePlugin Create() + { + return ProbePluginFactory.Create(); + } +} +#endif diff --git a/tests/CheatEngine.SDK.LiveProbe/README.md b/tests/CheatEngine.SDK.LiveProbe/README.md index 279b66c5..d8b19fb1 100644 --- a/tests/CheatEngine.SDK.LiveProbe/README.md +++ b/tests/CheatEngine.SDK.LiveProbe/README.md @@ -3,7 +3,22 @@ `CheatEngine.SDK.LiveProbe` is a manually loaded **evidence harness**, not a unit-test project, sample plugin, package asset, or normal CI input. It records CE 7.7 behaviours that fixture tests cannot establish: the raw managed bootstrap argument, the disputed packed-record tail, `synchronize`, per-thread Lua states and registry sharing, external -`resetLuaState`, CE userdata, and callback cleanup on plugin disable. +`resetLuaState`, CE userdata, callback cleanup on plugin disable, and the Checkpoint B hooks of the qualification runner. + +## Objective + +Give the local qualification runner ([`eng/qualification`](../../eng/qualification/README.md)) and a human operator one +plugin that exposes, through Lua-console commands, the facts the exact-host (C3) qualification scenarios record: plugin +id and epoch (Q05), the reported exports-table size (Q03), the raw second bootstrap integer (Q04), a managed exception +inside a Lua callback (Q14), lifecycle faults on demand (Q06, Q08), a message pump during a callback (Q07), the +non-ASCII plugin name (Q05.a) and where the SDK assemblies were loaded from (Q40). + +## Why it exists + +Only a real Cheat Engine host can show these behaviours, and C1/C2 success is never host evidence +([qualification levels](../../docs/qualification/README.md)). The harness keeps every probe opt-in and fail-closed, so +loading it by mistake observes nothing and changes nothing. The solution compiles it, so a compile break is caught by CI; +CI never loads or runs it. ## Safety boundary @@ -23,14 +38,72 @@ SDK's conservative packed 36-byte bootstrap record. It runs only during bootstra isolated to the CE process, not the target, but must still be run only with a disposable test setup. A canary value that survives **does not prove allocation capacity** on its own; preserve all raw observations for review. -The harness does not write a result file: it logs raw values through `HostLog`/`OutputDebugString`, so capture it with a -debugger or DebugView and save the transcript outside the repository. Do not place installed CE binaries, target -binaries, manifests containing sensitive paths, or captured process memory in source control. +The harness does not write a result file: it logs raw values through `HostLog`/`OutputDebugString` and returns them to +the Lua caller. The qualification runner's Lua driver records the returned values in a redacted event log; a manual +operator keeps the transcript outside the repository. Do not place installed CE binaries, target binaries, manifests +containing sensitive paths, or captured process memory in source control. + +## How it works + +| File | Content | +|-----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `CE77LiveProbeBootstrap.cs` | The hand-written `CESDK.CESDK.CEPluginInitialize(nint, int)`. It records the second integer raw, then forwards to `PluginHost.InitializeManaged`. With `LIVEPROBE_NON_ASCII_NAME` it selects the non-ASCII factory. | +| `ProbePluginFactory.cs`, `ProbePluginFactoryNonAscii.cs` | The factories. `Create` first evaluates the fault switch. The non-ASCII factory exists only in the `-p:LiveProbeNonAsciiName=true` build. | +| `Ce77LiveProbePlugin.cs` | `OnEnable` registers the console commands, re-checks the gates and applies the `OnEnable` fault; `OnDisable` unregisters them and applies the `OnDisable` fault. | +| `ProbeConsole.cs`, `ProbeHostGlobals.cs` | The `[LuaFunction]` console commands below and the one `[LuaGlobal("getOpenedProcessID")]` binding. | +| `LiveProbeAuthorization.cs`, `AuthorizationDecision.cs` | The fail-closed gate: exact host hash and version, operator acknowledgement, unexpired manifest, live hash-verified disposable target. | +| `LiveProbeState.cs` | Process-local observations and the command implementations. Every command that acts re-evaluates the gate first. | +| `LiveProbeFaultInjection.cs`, `LiveProbeFaultStage.cs`, `LiveProbeFaultDecision.cs` | The fault switch (see below). Lua-free, compile-linked into the tests. | +| `LiveProbeHostFacts.cs`, `LiveProbeStatusSnapshot.cs`, `LiveProbeStatusReport.cs` | The status record: `PluginHost` facts, gates, fault decisions and observations serialized as `ce77-live-probe-status-v1` JSON. Lua-free, compile-linked into the tests. | +| `HostProfileObservation.cs` | The `ce77-live-host-profile-v1` identity record of host, Lua module, bridge, plugin and target files. | + +### Console commands + +Every command returns a self-contained string. Commands that act (all except the two status commands) re-evaluate the +authorization manifest, the target image and CE's opened PID immediately before acting, and return +`Live probe denied: ` otherwise. + +| Command | Observation | Used by | +|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------| +| `ce77_live_probe_status()` | Human-readable: bootstrap calls, the raw second integer (`opaqueSecondInt`, never labelled size or version), `PluginHost.LastInitRecordArgument`, phase, plugin id, epoch, reported exports size, gates, prior observations. | operator | +| `ce77_live_probe_status_json()` | The same facts as one `ce77-live-probe-status-v1` JSON object, plus assembly locations, MVIDs, the Hosting load context and every fault-switch decision. Not gated: it reads process-local facts only. | Q03, Q04, Q05, Q06, Q08, Q40 | +| `ce77_live_probe_host_profile()` | One `ce77-live-host-profile-v1` JSON identity record for the authorized CE host, loaded Lua module, adjacent bridge, plugin and disposable target. | Q40 | +| `ce77_live_probe_throw_managed_exception()` | Throws `InvalidOperationException("CE 7.7 live probe deliberate managed exception (Q14).")` inside the generated thunk; call it under `pcall` and record the Lua error. | Q14 | +| `ce77_live_probe_pump_messages(seconds)` | Pumps CE's messages for 1–60 seconds from admitted main-thread work and returns a `ce77-live-probe-pump-v1` JSON record of the lifecycle phases seen. The operator unticks the plugin meanwhile. An observation, not a promise. | Q07 | +| `ce77_live_probe_begin_synchronize()` then `ce77_live_probe_synchronize_status()` | Worker, thunk and nested-invoke managed thread IDs; return round-trip and propagated exception. | operator | +| `ce77_live_probe_begin_lua_threads()` then `ce77_live_probe_lua_threads_status()` | GUI and worker `lua_State*` identities and a private raw-registry marker read by the worker. Do not execute other Lua for one second. | Q19 | +| `ce77_live_probe_snapshot_before_reset()` / `ce77_live_probe_snapshot_after_reset()` | State pointer, SDK epoch and reference slot before and after an operator-run `resetLuaState()`; whether the old SDK reference still pushed. The harness never calls the reset. | Q17, Q18 | +| `ce77_live_probe_userdata()` | `type(getMainForm())` and `tostring(getMainForm())`, without retaining the userdata or invoking the host-object pusher. | operator | +| `ce77_live_probe_prepare_callback_shutdown()` | Installs a counter callback and leaves it registered in `OnDisable`, so `LuaRuntime.Detach` must neutralize it. Call `pcall(ce77_live_probe_callback_shutdown)` before and after disabling. | Q15 | + +### Fault switch (Q06, Q08) + +A file named `liveprobe.fault.json` next to `CheatEngine.SDK.LiveProbe.dll` selects one lifecycle stage that throws: + +```json +{ "schema": "ce77-live-probe-fault-v1", "throwIn": "OnEnable" } +``` + +`throwIn` is `None`, `FactoryCreate` (the factory throws before constructing the plugin; the enable fails and the next +enable tries again), `OnEnable` (after the console commands are registered) or `OnDisable` (after they are +unregistered). The switch is read once per enable, without Lua, and only when the authorization gate allows: without +the manifest the file is never opened. An unknown schema, an unknown stage or malformed JSON is ignored and reported. Every +decision is logged and appears under `faultInjection` in `ce77_live_probe_status_json()`, with the list of stages that +actually threw. The runner writes and deletes the file in the bundle folder; it never lives in the repository. + +### Non-ASCII name build (Q05.a) + +`dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Release -p:LiveProbeNonAsciiName=true` +defines `LIVEPROBE_NON_ASCII_NAME`, and the bootstrap then registers the name `CheatEngine.SDK Live Probe é 日本` (one +Latin-1 character and two characters outside code page 1252). It settles, by observation, whether Cheat Engine 7.7 +decodes the name the SDK converts to the process ANSI code page +([`AnsiNameBuffer`](../../libs/CheatEngine.SDK.Hosting/Bootstrap/AnsiNameBuffer.cs)). The default build keeps the ASCII +name `CheatEngine.SDK CE 7.7 Live Probe`. The switch writes to the same output folder, so rebuild without it afterwards. ## Build and load -Build it manually; it is intentionally absent from `CheatEngine.SDK.slnx`, so ordinary SDK builds and CI never load or -run it. +The qualification runner builds the harness from the exact CI package into a clean folder and drives it; follow the +[local qualification protocol](../../docs/qualification/local-protocol.md). For a manual session: ```powershell dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Release @@ -38,9 +111,9 @@ dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Keep the complete `artifacts/bin/CheatEngine.SDK.LiveProbe/release/` folder together when loading `CheatEngine.SDK.LiveProbe.dll` in CE's plugin settings. It needs the SDK assemblies, `.deps.json`, -`.runtimeconfig.json` and `cheatengine-sdk-lua-bridge.dll` next to the plugin. Follow the CE/.NET runtime-host setup -requirements documented by [`CheatEngine.SDK.LivePlugin`](../CheatEngine.SDK.LivePlugin/README.md) before attempting a -live run. +`.runtimeconfig.json` and `cheatengine-sdk-lua-bridge.dll` next to the plugin. The supported host and runtime policy +are recorded in the [support profile](../../docs/qualification/support-profile.md); never edit an installed CE to run +this harness. Create a short-lived authorization file on a secure local volume. Substitute only the hash and PID of the disposable program that the operator has deliberately launched and attached in CE: @@ -59,36 +132,42 @@ program that the operator has deliberately launched and attached in CE: Set both environment variables in the same process tree that starts CE. Check `ce77_live_probe_status()` immediately after enabling. If it reports any denied gate, stop: none of the action commands should be used and no result is -evidence. - -## Console protocol - -Run every command from CE's Lua Engine and preserve the command, UTC time, returned text, DebugView transcript, CE -binary hash, target image hash, PID, architecture and manifest expiry with the result. Commands intentionally do not -guess a pass/fail conclusion. - -| Command | Observation | Operator action / interpretation | -|-----------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ce77_live_probe_status()` | Every raw bootstrap integer, tail-canary record, gate decision and prior outcome. | The second integer is reported as `opaqueSecondInt`; never label it size/version from this output alone. | -| `ce77_live_probe_host_profile()` | One JSON identity record for the authorized CE host, loaded Lua module, adjacent bridge binary, plugin binary, and disposable target. | Save the returned JSON with the DebugView transcript outside the repository. An observed file is not a live qualification until the artifact is reviewed against the catalogue. | -| `ce77_live_probe_begin_synchronize()` then `ce77_live_probe_synchronize_status()` | Worker, thunk and nested-invoke managed thread IDs; return round-trip and propagated exception. | Do not block the GUI; poll until completion. Compare IDs with the enable-thread log. | -| `ce77_live_probe_begin_lua_threads()` then `ce77_live_probe_lua_threads_status()` | GUI and worker `lua_State*` identities and a private raw-registry marker read by the worker. | Do not execute other Lua for one second. This is a narrow observation, not permission for arbitrary concurrent Lua. | -| `ce77_live_probe_snapshot_before_reset()` | State pointer, SDK epoch and reference slot before reset. | Manually call CE's `resetLuaState()`; the harness never calls it. | -| `ce77_live_probe_snapshot_after_reset()` | State/epoch after reset and whether the old SDK reference pushed. | Record raw outcome. External reset without a corresponding SDK notification remains unsupported. | -| `ce77_live_probe_userdata()` | `type(getMainForm())` and `tostring(getMainForm())`. | This observes CE userdata without retaining it or invoking the host-object pusher. | -| `ce77_live_probe_prepare_callback_shutdown()` | Installs a counter callback. | Call `pcall(ce77_live_probe_callback_shutdown)` once; disable the plugin; call it again under `pcall`; then re-enable and collect `status()`. | - -The callback probe intentionally leaves the callback registered in `OnDisable`; `LuaRuntime.Detach` is responsible for -neutralizing it. Do not force-unload assemblies or use CE's process-killing actions to end a run. Disable the plugin, -close CE normally, delete the short-lived authorization manifest, and terminate only the disposable target through its -normal cleanup route. +evidence. Disable the plugin, close CE normally, delete the short-lived authorization manifest, and terminate only the +disposable target through its normal cleanup route. Do not force-unload assemblies or use CE's process-killing actions. + +## Promise + +- The harness is compiled by CI through `CheatEngine.SDK.slnx` as an x64 dynamic-loading plugin that is not a test + module and never packs (`QualificationProjectShapeTests.LiveProbe_is_in_the_solution_as_an_x64_dynamic_loading_plugin_that_never_packs`). +- A fresh authorization is required before every acting command, and a changed manifest, target image or CE target + PID is refused (`LiveProbeStateTests`). +- The raw second bootstrap integer and the reported exports size are reported without interpretation + (`LiveProbeStatusTests.Status_reports_the_exports_size_and_the_raw_second_bootstrap_integer_without_interpretation`). +- Without authorization the exception and pump hooks are inert, and the pump refuses a duration outside 1–60 seconds + before any host call (`LiveProbeStatusTests`). +- The fault switch is never read without authorization, selects exactly the requested stage, and ignores and reports an + absent, unreadable or unknown switch (`LiveProbeFaultInjectionTests`). +- Missing, locked or vanishing identity files are reported as typed outcomes, never as a crash + (`HostProfileObservationTests`). +- No live test is invoked by `dotnet test`, normal CI, Release validation or packaging: the solution only compiles it, + and no workflow references the runner (`LocalQualificationRunnerTests.No_workflow_references_the_local_qualification_runner`). + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.LiveProbe.Tests.csproj +``` + +The tests compile the Lua-free sources of this harness directly (see +[`CheatEngine.SDK.LiveProbe.Tests`](../CheatEngine.SDK.LiveProbe.Tests/README.md)); a host run is the qualification +runner's job. ## Scope and limitations -- `ce77_live_probe_host_profile()` and every protected command re-evaluate the authorization manifest, target image, - and CE opened-process PID immediately before acting. This is a current-state check, not proof that CE did not select - another target between observations; PID reuse by an identical executable is not distinguishable without an - operator-supplied incarnation value, which the `ce77-live-probe-v1` manifest does not contain. +- Every protected command re-evaluates the gate immediately before acting. This is a current-state check, not proof + that CE did not select another target between observations; PID reuse by an identical executable is not + distinguishable without an operator-supplied incarnation value, which the `ce77-live-probe-v1` manifest does not + contain. - The plugin does not implement the classic native plugin Type-6 popup callback. That callback belongs to the classic ABI and needs a separately compiled, header-pinned native probe after the CE 7.7 header/Pascal divergence has been resolved. @@ -101,6 +180,7 @@ normal cleanup route. reset/generation contract. - The worker-and-registry observation is opt-in only. A distinct worker Lua pointer may be a coroutine sharing the main virtual machine, heap and registry, so it is not evidence of independent heaps or safe concurrent execution. -- No live test is invoked by `dotnet test`, normal CI, Release validation or packaging. +- The pump hook reports what happened while the operator acted; it does not promise how Cheat Engine delivers a + disable during a callback. Result recording and evidence rules: [local qualification protocol](../../docs/qualification/local-protocol.md). From 72f2447a016844e6ccc544499988bc7c13c77171 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:26:44 +0200 Subject: [PATCH 021/199] Add the v0 qualification schemas Qualification results need machine-checkable shapes before anything can cite them. Add four JSON Schema draft 2020-12 documents under docs/qualification/schemas, every object closed: - support-profile: the documentary public-source profile and the qualifiable CE 7.7.0.10621 x64 managed-hostfxr profile, the Checkpoint A decisions, the unsupported routes and dated measurements. - qualification-matrix: one row per Q01-Q48 scenario and sub-row, with the scenario block (preconditions, operation, expected, expected category) and one cell per level. C0-C2 cells take executed, traited CI tests; C3/C4 cells take committed receipts only, name the profile and, once executed, the tree and package they came from. - qualification-receipt: one redacted C3/C4 run with operator, load route, tree, exact CI package identities, host, bridge, per-file bundle hashes, target, HKCU diff (names only), timings and its event log. - qualification-events: the structured transcript committed next to a receipt. They follow the frozen contract in shared-contracts sections 2.0-2.3 with the ratified additions A-SQUAL-1 to A-SQUAL-4, including the rule that a hash naming a committed JSON document is computed after CRLF-to-LF normalization. Other lots and the Client copy these files, so they land before the validator that enforces them. --- CheatEngine.SDK.slnx | 6 + .../qualification-events.v0.schema.json | 89 +++ .../qualification-matrix.v0.schema.json | 628 ++++++++++++++++ .../qualification-receipt.v0.schema.json | 675 ++++++++++++++++++ .../schemas/support-profile.v0.schema.json | 609 ++++++++++++++++ 5 files changed, 2007 insertions(+) create mode 100644 docs/qualification/schemas/qualification-events.v0.schema.json create mode 100644 docs/qualification/schemas/qualification-matrix.v0.schema.json create mode 100644 docs/qualification/schemas/qualification-receipt.v0.schema.json create mode 100644 docs/qualification/schemas/support-profile.v0.schema.json diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index f7c8ec18..43b35a82 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -51,6 +51,12 @@ + + + + + + diff --git a/docs/qualification/schemas/qualification-events.v0.schema.json b/docs/qualification/schemas/qualification-events.v0.schema.json new file mode 100644 index 00000000..8dfe42fb --- /dev/null +++ b/docs/qualification/schemas/qualification-events.v0.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/docs/qualification/schemas/qualification-events.v0.schema.json", + "title": "Qualification event log, v0", + "description": "The structured, redacted transcript of one receipt (.events.json), committed next to it. Events come from the runner, the autorun Lua driver, the plugin under test (through the values its Lua functions return) and the operator. User and machine names, local paths outside the placeholders , , and , process ids and addresses outside the scenario, and raw debug output are removed before the file is written; long lists are summarized. The receipt's eventLog.sha256 is the SHA-256 of this file's UTF-8 bytes after CRLF is normalized to LF. Validated by tests/CheatEngine.SDK.Repository.Tests/Qualification.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "receiptId", + "events" + ], + "properties": { + "schema": { + "const": "cheatengine-qualification-events/v0" + }, + "receiptId": { + "type": "string", + "pattern": "^R-\\d{8}T\\d{6}Z-Q\\d{2}(\\.[a-z])?-[0-9a-f]{8}$" + }, + "events": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/event" + } + }, + "summarized": { + "description": "Present when the runner shortened the log: how many events were kept and how many were dropped from the middle.", + "$ref": "#/$defs/summary" + } + }, + "$defs": { + "event": { + "type": "object", + "additionalProperties": false, + "required": [ + "tMs", + "source", + "kind", + "message" + ], + "properties": { + "tMs": { + "type": "integer", + "minimum": 0 + }, + "source": { + "enum": [ + "Runner", + "Driver", + "Plugin", + "Operator" + ] + }, + "kind": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9.]*$" + }, + "message": { + "type": "string" + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "keptFirst", + "keptLast", + "dropped" + ], + "properties": { + "keptFirst": { + "type": "integer", + "minimum": 0 + }, + "keptLast": { + "type": "integer", + "minimum": 0 + }, + "dropped": { + "type": "integer", + "minimum": 1 + } + } + } + } +} diff --git a/docs/qualification/schemas/qualification-matrix.v0.schema.json b/docs/qualification/schemas/qualification-matrix.v0.schema.json new file mode 100644 index 00000000..ba4d1610 --- /dev/null +++ b/docs/qualification/schemas/qualification-matrix.v0.schema.json @@ -0,0 +1,628 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/docs/qualification/schemas/qualification-matrix.v0.schema.json", + "title": "Qualification matrix Q01-Q48, v0", + "description": "One row per qualification scenario of the audit (analyses/20, Q01 to Q48) and per declared sub-row, with one cell per evidence level C0 to C4. A C0-C2 cell cites executed, traited CI tests; a C3/C4 cell cites committed receipts only, so C1/C2 success never counts as host qualification. There is no global percentage. A sha256 that names a committed JSON document (a receipt) is the SHA-256 of its UTF-8 bytes after CRLF is normalized to LF. Validated by tests/CheatEngine.SDK.Repository.Tests/Qualification.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "repository", + "audit", + "profiles", + "rows" + ], + "properties": { + "schema": { + "const": "cheatengine-qualification-matrix/v0" + }, + "repository": { + "enum": [ + "CheatEngineNet/CheatEngine.SDK", + "CheatEngineNet/CheatEngine.Client" + ] + }, + "audit": { + "type": "object", + "additionalProperties": false, + "required": [ + "manifestSha256" + ], + "properties": { + "manifestSha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/profileId" + } + }, + "rows": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/row" + } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "gitObjectId": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "text": { + "type": "string", + "pattern": "\\S" + }, + "profileId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]*$" + }, + "qualificationId": { + "type": "string", + "pattern": "^Q(0[1-9]|[1-3][0-9]|4[0-8])(\\.[a-z])?$" + }, + "level": { + "enum": [ + "C0", + "C1", + "C2", + "C3", + "C4" + ] + }, + "status": { + "enum": [ + "NotExecuted", + "Passed", + "Failed", + "NotApplicable" + ] + }, + "passKind": { + "enum": [ + "Functional", + "RefusalVerified" + ] + }, + "evidenceKind": { + "enum": [ + "ObservedSource", + "DeclaredRepo", + "Deduced", + "ToQualify", + "ProposedDecision", + "ObservedHost", + "ExactBinary", + "ExactInstalledFile", + "PinnedUpstream", + "ObservedLive", + "Inferred", + "Unknown" + ] + }, + "row": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "parent", + "title", + "titleFr", + "owner", + "requiredLevels", + "blocks", + "audit", + "scenario", + "levels" + ], + "properties": { + "id": { + "$ref": "#/$defs/qualificationId" + }, + "parent": { + "anyOf": [ + { + "$ref": "#/$defs/qualificationId" + }, + { + "type": "null" + } + ] + }, + "subRows": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/qualificationId" + } + }, + "title": { + "$ref": "#/$defs/text" + }, + "titleFr": { + "$ref": "#/$defs/text" + }, + "owner": { + "enum": [ + "SDK", + "Client", + "Both" + ] + }, + "requiredLevels": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/level" + } + }, + "blocks": { + "$ref": "#/$defs/text" + }, + "audit": { + "$ref": "#/$defs/auditReference" + }, + "scenario": { + "$ref": "#/$defs/scenario" + }, + "levels": { + "type": "object", + "additionalProperties": false, + "properties": { + "C0": { + "$ref": "#/$defs/fixtureCell" + }, + "C1": { + "$ref": "#/$defs/fixtureCell" + }, + "C2": { + "$ref": "#/$defs/fixtureCell" + }, + "C3": { + "$ref": "#/$defs/hostCell" + }, + "C4": { + "$ref": "#/$defs/hostCell" + } + } + } + } + }, + "auditReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "ref", + "findings" + ], + "properties": { + "ref": { + "type": "string", + "pattern": "^analyses/\\d{2} Q\\d{2}(\\.[a-z])?( ; analyses/\\d{2} l\\.\\d+)?$" + }, + "findings": { + "type": "array", + "items": { + "type": "string", + "pattern": "^F(0[1-9]|1[0-6])$" + } + } + } + }, + "scenario": { + "type": "object", + "additionalProperties": false, + "required": [ + "preconditions", + "operation", + "expected", + "expectedCategory" + ], + "properties": { + "preconditions": { + "type": "array", + "items": { + "$ref": "#/$defs/text" + } + }, + "operation": { + "$ref": "#/$defs/text" + }, + "expected": { + "$ref": "#/$defs/text" + }, + "expectedCategory": { + "enum": [ + "Effect", + "Partial", + "Refused", + "Unknown" + ] + } + } + }, + "fixtureCell": { + "description": "A C0, C1 or C2 cell. Its evidence is executed CI tests (Automated) or a CI run, never a host receipt.", + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "evidenceKind" + ], + "properties": { + "status": { + "$ref": "#/$defs/status" + }, + "passKind": { + "anyOf": [ + { + "$ref": "#/$defs/passKind" + }, + { + "type": "null" + } + ] + }, + "evidenceKind": { + "$ref": "#/$defs/evidenceKind" + }, + "profileId": { + "$ref": "#/$defs/profileId" + }, + "evidence": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/fixtureEvidence" + } + }, + "justification": { + "anyOf": [ + { + "$ref": "#/$defs/text" + }, + { + "type": "null" + } + ] + }, + "expected": { + "anyOf": [ + { + "$ref": "#/$defs/status" + }, + { + "type": "null" + } + ] + }, + "transferJustification": { + "anyOf": [ + { + "$ref": "#/$defs/text" + }, + { + "type": "null" + } + ] + }, + "date": { + "$ref": "#/$defs/date" + } + }, + "allOf": [ + { + "$ref": "#/$defs/statusRules" + } + ] + }, + "hostCell": { + "description": "A C3 or C4 cell. It names the qualifiable profile and, once executed, cites committed receipts and the tree and package they were produced from.", + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "evidenceKind", + "profileId" + ], + "properties": { + "status": { + "$ref": "#/$defs/status" + }, + "passKind": { + "anyOf": [ + { + "$ref": "#/$defs/passKind" + }, + { + "type": "null" + } + ] + }, + "evidenceKind": { + "$ref": "#/$defs/evidenceKind" + }, + "profileId": { + "$ref": "#/$defs/profileId" + }, + "evidence": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/receiptEvidence" + } + }, + "justification": { + "anyOf": [ + { + "$ref": "#/$defs/text" + }, + { + "type": "null" + } + ] + }, + "expected": { + "anyOf": [ + { + "$ref": "#/$defs/status" + }, + { + "type": "null" + } + ] + }, + "treeHash": { + "$ref": "#/$defs/gitObjectId" + }, + "nupkgSha256": { + "$ref": "#/$defs/sha256" + }, + "transferJustification": { + "anyOf": [ + { + "$ref": "#/$defs/text" + }, + { + "type": "null" + } + ] + }, + "date": { + "$ref": "#/$defs/date" + } + }, + "allOf": [ + { + "$ref": "#/$defs/statusRules" + }, + { + "if": { + "required": [ + "status" + ], + "properties": { + "status": { + "enum": [ + "Passed", + "Failed" + ] + } + } + }, + "then": { + "required": [ + "treeHash", + "nupkgSha256" + ] + } + } + ] + }, + "statusRules": { + "allOf": [ + { + "if": { + "required": [ + "status" + ], + "properties": { + "status": { + "const": "Passed" + } + } + }, + "then": { + "required": [ + "passKind", + "evidence", + "date" + ], + "properties": { + "passKind": { + "$ref": "#/$defs/passKind" + } + } + }, + "else": { + "properties": { + "passKind": { + "type": "null" + } + } + } + }, + { + "if": { + "required": [ + "status" + ], + "properties": { + "status": { + "const": "Failed" + } + } + }, + "then": { + "required": [ + "evidence", + "date", + "justification" + ] + } + }, + { + "if": { + "required": [ + "status" + ], + "properties": { + "status": { + "const": "NotApplicable" + } + } + }, + "then": { + "required": [ + "justification" + ], + "properties": { + "justification": { + "$ref": "#/$defs/text" + } + } + } + }, + { + "if": { + "required": [ + "status" + ], + "properties": { + "status": { + "const": "NotExecuted" + } + } + }, + "then": { + "properties": { + "evidence": false, + "date": false, + "treeHash": false, + "nupkgSha256": false, + "transferJustification": false + } + } + } + ] + }, + "fixtureEvidence": { + "if": { + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "CiRun" + } + } + }, + "then": { + "$ref": "#/$defs/ciRunEvidence" + }, + "else": { + "$ref": "#/$defs/automatedEvidence" + } + }, + "automatedEvidence": { + "description": "An executed CI test: the project must be a *.Tests module of the solution, the file must exist and the method must carry the trait.", + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "project", + "file", + "test", + "trait" + ], + "properties": { + "kind": { + "const": "Automated" + }, + "project": { + "type": "string", + "pattern": "^tests/[A-Za-z0-9._-]+\\.Tests/[A-Za-z0-9._-]+\\.Tests\\.csproj$" + }, + "file": { + "type": "string", + "pattern": "^tests/[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*\\.cs$" + }, + "test": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*\\.[A-Za-z_][A-Za-z0-9_]*$" + }, + "trait": { + "type": "string", + "pattern": "^Qualification=Q(0[1-9]|[1-3][0-9]|4[0-8])(\\.[a-z])?$" + } + } + }, + "ciRunEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "url" + ], + "properties": { + "kind": { + "const": "CiRun" + }, + "url": { + "type": "string", + "pattern": "^https://github\\.com/CheatEngineNet/CheatEngine\\.(SDK|Client)/actions/runs/\\d+(/attempts/\\d+)?$" + } + } + }, + "receiptEvidence": { + "description": "A committed receipt produced by eng/qualification/Invoke-LocalQualification.ps1.", + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "receiptId", + "path", + "sha256" + ], + "properties": { + "kind": { + "const": "Receipt" + }, + "receiptId": { + "type": "string", + "pattern": "^R-\\d{8}T\\d{6}Z-Q\\d{2}(\\.[a-z])?-[0-9a-f]{8}$" + }, + "path": { + "type": "string", + "pattern": "^docs/qualification/receipts/Q\\d{2}(\\.[a-z])?/R-\\d{8}T\\d{6}Z-Q\\d{2}(\\.[a-z])?-[0-9a-f]{8}\\.json$" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + } + } + } +} diff --git a/docs/qualification/schemas/qualification-receipt.v0.schema.json b/docs/qualification/schemas/qualification-receipt.v0.schema.json new file mode 100644 index 00000000..e0d3bf83 --- /dev/null +++ b/docs/qualification/schemas/qualification-receipt.v0.schema.json @@ -0,0 +1,675 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/docs/qualification/schemas/qualification-receipt.v0.schema.json", + "title": "Exact-host qualification receipt, v0", + "description": "The committed, redacted record of one C3 or C4 scenario run by eng/qualification/Invoke-LocalQualification.ps1 on a sandbox copy of the exact Cheat Engine host, with the exact CI package. It names the tree, package, host, bridge, bundles, target and registry facts it is valid for; results transfer to another combination only with a transferJustification. The structured event log is a separate committed document; eventLog.sha256 and every sha256 that names a committed JSON document are the SHA-256 of its UTF-8 bytes after CRLF is normalized to LF. No Cheat Engine or target binary, authorization manifest, registry export or raw debug output is ever committed. Validated by tests/CheatEngine.SDK.Repository.Tests/Qualification.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "receiptId", + "qualificationId", + "level", + "profileId", + "operator", + "loadRoute", + "repository", + "runner", + "package", + "host", + "bridge", + "bundles", + "target", + "registry", + "preconditions", + "operation", + "expected", + "observed", + "status", + "evidenceKind", + "justification", + "timings", + "eventLog", + "transferJustification", + "createdUtc" + ], + "properties": { + "schema": { + "const": "cheatengine-qualification-receipt/v0" + }, + "receiptId": { + "type": "string", + "pattern": "^R-\\d{8}T\\d{6}Z-Q\\d{2}(\\.[a-z])?-[0-9a-f]{8}$" + }, + "qualificationId": { + "type": "string", + "pattern": "^Q(0[1-9]|[1-3][0-9]|4[0-8])(\\.[a-z])?$" + }, + "level": { + "enum": [ + "C3", + "C4" + ] + }, + "profileId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]*$" + }, + "operator": { + "description": "GitHub handle of the operator who ran and confirmed the scenario.", + "type": "string", + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$" + }, + "loadRoute": { + "enum": [ + "LuaLoadPlugin", + "SettingsPluginsUi", + "None" + ] + }, + "repository": { + "$ref": "#/$defs/repository" + }, + "runner": { + "$ref": "#/$defs/runner" + }, + "package": { + "$ref": "#/$defs/package" + }, + "host": { + "$ref": "#/$defs/host" + }, + "bridge": { + "$ref": "#/$defs/bridge" + }, + "bundles": { + "type": "array", + "items": { + "$ref": "#/$defs/bundle" + } + }, + "target": { + "$ref": "#/$defs/target" + }, + "registry": { + "$ref": "#/$defs/registry" + }, + "preconditions": { + "type": "array", + "items": { + "$ref": "#/$defs/text" + } + }, + "operation": { + "$ref": "#/$defs/text" + }, + "expected": { + "$ref": "#/$defs/text" + }, + "observed": { + "$ref": "#/$defs/text" + }, + "status": { + "enum": [ + "Passed", + "Failed", + "NotApplicable" + ] + }, + "passKind": { + "anyOf": [ + { + "enum": [ + "Functional", + "RefusalVerified" + ] + }, + { + "type": "null" + } + ] + }, + "evidenceKind": { + "const": "ObservedHost" + }, + "justification": { + "anyOf": [ + { + "$ref": "#/$defs/text" + }, + { + "type": "null" + } + ] + }, + "timings": { + "$ref": "#/$defs/timings" + }, + "eventLog": { + "$ref": "#/$defs/eventLog" + }, + "transferJustification": { + "anyOf": [ + { + "$ref": "#/$defs/text" + }, + { + "type": "null" + } + ] + }, + "createdUtc": { + "$ref": "#/$defs/utc" + } + }, + "allOf": [ + { + "if": { + "required": [ + "status" + ], + "properties": { + "status": { + "const": "Passed" + } + } + }, + "then": { + "required": [ + "passKind" + ], + "properties": { + "passKind": { + "enum": [ + "Functional", + "RefusalVerified" + ] + } + } + }, + "else": { + "properties": { + "passKind": { + "type": "null" + } + } + } + }, + { + "if": { + "required": [ + "status" + ], + "properties": { + "status": { + "enum": [ + "Failed", + "NotApplicable" + ] + } + } + }, + "then": { + "properties": { + "justification": { + "$ref": "#/$defs/text" + } + } + } + } + ], + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "sha512Base64": { + "type": "string", + "pattern": "^[A-Za-z0-9+/]{86}==$" + }, + "gitObjectId": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "utc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?Z$" + }, + "text": { + "type": "string", + "pattern": "\\S" + }, + "relativePath": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$" + }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "treeHash", + "commit", + "pullRequest" + ], + "properties": { + "name": { + "enum": [ + "CheatEngineNet/CheatEngine.SDK", + "CheatEngineNet/CheatEngine.Client" + ] + }, + "treeHash": { + "$ref": "#/$defs/gitObjectId" + }, + "commit": { + "$ref": "#/$defs/gitObjectId" + }, + "pullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/pullRequest" + }, + { + "type": "null" + } + ] + } + } + }, + "pullRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "number", + "headSha" + ], + "properties": { + "number": { + "type": "integer", + "minimum": 1 + }, + "headSha": { + "$ref": "#/$defs/gitObjectId" + } + } + }, + "runner": { + "type": "object", + "additionalProperties": false, + "required": [ + "script", + "scriptSha256", + "sourceRepository", + "sourceCommit", + "mutex" + ], + "properties": { + "script": { + "const": "eng/qualification/Invoke-LocalQualification.ps1" + }, + "scriptSha256": { + "$ref": "#/$defs/sha256" + }, + "sourceRepository": { + "const": "CheatEngineNet/CheatEngine.SDK" + }, + "sourceCommit": { + "$ref": "#/$defs/gitObjectId" + }, + "mutex": { + "const": "Global\\ce-lab" + } + } + }, + "package": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "nupkgSha256", + "contentHashSha512", + "source", + "ciRunUrl" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^CheatEngine\\.(SDK|Client)$" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$" + }, + "nupkgSha256": { + "$ref": "#/$defs/sha256" + }, + "contentHashSha512": { + "$ref": "#/$defs/sha512Base64" + }, + "source": { + "enum": [ + "CiArtifact", + "NuGetOrg" + ] + }, + "ciRunUrl": { + "anyOf": [ + { + "type": "string", + "pattern": "^https://github\\.com/CheatEngineNet/CheatEngine\\.(SDK|Client)/actions/runs/\\d+(/attempts/\\d+)?$" + }, + { + "type": "null" + } + ] + } + }, + "if": { + "required": [ + "source" + ], + "properties": { + "source": { + "const": "CiArtifact" + } + } + }, + "then": { + "properties": { + "ciRunUrl": { + "type": "string" + } + } + } + }, + "host": { + "type": "object", + "additionalProperties": false, + "required": [ + "ceExeName", + "ceExeSha256", + "ceFileVersion", + "luaDllSha256", + "runtimeconfigSha256", + "autorunSha256", + "sandboxCopy", + "osVersion", + "dotnetRuntimes" + ], + "properties": { + "ceExeName": { + "type": "string", + "pattern": "^[^\\\\/:]+\\.exe$" + }, + "ceExeSha256": { + "$ref": "#/$defs/sha256" + }, + "ceFileVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+\\.\\d+$" + }, + "luaDllSha256": { + "$ref": "#/$defs/sha256" + }, + "runtimeconfigSha256": { + "$ref": "#/$defs/sha256" + }, + "autorunSha256": { + "description": "SHA-256 of the generated autorun driver that ran, or null when no driver was installed.", + "anyOf": [ + { + "$ref": "#/$defs/sha256" + }, + { + "type": "null" + } + ] + }, + "sandboxCopy": { + "const": true + }, + "osVersion": { + "$ref": "#/$defs/text" + }, + "dotnetRuntimes": { + "type": "array", + "items": { + "$ref": "#/$defs/text" + } + } + } + }, + "bridge": { + "type": "object", + "additionalProperties": false, + "required": [ + "sha256", + "sourceFingerprint" + ], + "properties": { + "sha256": { + "$ref": "#/$defs/sha256" + }, + "sourceFingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}:[0-9a-f]{64}$" + } + } + }, + "bundle": { + "description": "An out-of-repository plugin folder built from the exact package. Only its name, its manifest hash and the hash of every file it contains are recorded.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "manifestSha256", + "files" + ], + "properties": { + "name": { + "enum": [ + "LiveProbe", + "LiveProbeNonAscii", + "LivePlugin", + "CoexistenceA", + "CoexistenceB", + "CoexistenceShared" + ] + }, + "manifestSha256": { + "$ref": "#/$defs/sha256" + }, + "files": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/bundleFile" + } + } + } + }, + "bundleFile": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256" + ], + "properties": { + "path": { + "$ref": "#/$defs/relativePath" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "arch", + "sha256" + ], + "properties": { + "kind": { + "enum": [ + "QualificationTarget", + "GtutorialI386", + "None" + ] + }, + "arch": { + "anyOf": [ + { + "enum": [ + "x64", + "x86" + ] + }, + { + "type": "null" + } + ] + }, + "sha256": { + "anyOf": [ + { + "$ref": "#/$defs/sha256" + }, + { + "type": "null" + } + ] + } + } + }, + "registry": { + "type": "object", + "additionalProperties": false, + "required": [ + "key", + "exportBeforeSha256", + "exportAfterSha256", + "restored", + "diff" + ], + "properties": { + "key": { + "const": "HKCU\\Software\\Cheat Engine" + }, + "exportBeforeSha256": { + "$ref": "#/$defs/sha256" + }, + "exportAfterSha256": { + "$ref": "#/$defs/sha256" + }, + "restored": { + "type": "boolean" + }, + "diff": { + "$ref": "#/$defs/registryDiff" + } + } + }, + "registryDiff": { + "description": "Counts and names only; registry data is never recorded.", + "type": "object", + "additionalProperties": false, + "required": [ + "added", + "removed", + "changed", + "valueNames" + ], + "properties": { + "added": { + "type": "integer", + "minimum": 0 + }, + "removed": { + "type": "integer", + "minimum": 0 + }, + "changed": { + "type": "integer", + "minimum": 0 + }, + "valueNames": { + "type": "array", + "items": { + "$ref": "#/$defs/text" + } + } + } + }, + "timings": { + "type": "object", + "additionalProperties": false, + "required": [ + "startedUtc", + "finishedUtc", + "durationMs" + ], + "properties": { + "startedUtc": { + "$ref": "#/$defs/utc" + }, + "finishedUtc": { + "$ref": "#/$defs/utc" + }, + "durationMs": { + "type": "integer", + "minimum": 0 + }, + "ceStartMs": { + "type": "integer", + "minimum": 0 + }, + "pluginLoadMs": { + "type": "integer", + "minimum": 0 + }, + "operationMs": { + "type": "integer", + "minimum": 0 + }, + "indicative": { + "description": "True when the run was allowed next to other build processes; timings are then indicative only.", + "type": "boolean" + } + } + }, + "eventLog": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256", + "format", + "redactions" + ], + "properties": { + "path": { + "type": "string", + "pattern": "^R-\\d{8}T\\d{6}Z-Q\\d{2}(\\.[a-z])?-[0-9a-f]{8}\\.events\\.json$" + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "format": { + "const": "cheatengine-qualification-events/v0" + }, + "redactions": { + "type": "array", + "items": { + "$ref": "#/$defs/text" + } + } + } + } + } +} diff --git a/docs/qualification/schemas/support-profile.v0.schema.json b/docs/qualification/schemas/support-profile.v0.schema.json new file mode 100644 index 00000000..5eb7d160 --- /dev/null +++ b/docs/qualification/schemas/support-profile.v0.schema.json @@ -0,0 +1,609 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/docs/qualification/schemas/support-profile.v0.schema.json", + "title": "Cheat Engine support profiles, v0", + "description": "The Cheat Engine host profiles a qualification result can name, the Checkpoint A decisions and the unsupported routes. A documentary profile is never qualifiable. Values are measured or declared facts, never Cheat Engine documentation prose. A sha256 that names a committed JSON document is the SHA-256 of its UTF-8 bytes after CRLF is normalized to LF. Validated by tests/CheatEngine.SDK.Repository.Tests/Qualification.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "profiles", + "decisions", + "unsupportedRoutes" + ], + "properties": { + "schema": { + "const": "cheatengine-support-profile/v0" + }, + "profiles": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/profile" + } + }, + "decisions": { + "type": "array", + "items": { + "$ref": "#/$defs/decision" + } + }, + "unsupportedRoutes": { + "type": "array", + "items": { + "$ref": "#/$defs/unsupportedRoute" + } + }, + "measurements": { + "description": "Dated measurements behind the profile values, kept apart from values that are only declared by a repository file.", + "type": "array", + "items": { + "$ref": "#/$defs/measurement" + } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "gitObjectId": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "text": { + "type": "string", + "pattern": "\\S" + }, + "evidenceKind": { + "enum": [ + "ObservedSource", + "DeclaredRepo", + "Deduced", + "ToQualify", + "ProposedDecision", + "ObservedHost", + "ExactBinary", + "ExactInstalledFile", + "PinnedUpstream", + "ObservedLive", + "Inferred", + "Unknown" + ] + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind", + "qualifiable", + "qualificationStatus", + "description", + "celua", + "evidenceKind" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]*$" + }, + "kind": { + "enum": [ + "Documentary", + "Qualifiable" + ] + }, + "qualifiable": { + "type": "boolean" + }, + "qualificationStatus": { + "enum": [ + "NotExecuted", + "FixtureQualified", + "HostQualified" + ] + }, + "description": { + "$ref": "#/$defs/text" + }, + "source": { + "$ref": "#/$defs/source" + }, + "host": { + "$ref": "#/$defs/host" + }, + "lua": { + "$ref": "#/$defs/lua" + }, + "celua": { + "$ref": "#/$defs/celua" + }, + "runtime": { + "$ref": "#/$defs/runtime" + }, + "registry": { + "$ref": "#/$defs/registry" + }, + "qualifiedBackends": { + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "LocalProcess", + "FileAsProcess", + "CEServer" + ] + } + }, + "authorizedTargets": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/authorizedTarget" + } + }, + "sdkPluginContractVersion": { + "type": "integer", + "minimum": 1 + }, + "evidenceKind": { + "$ref": "#/$defs/evidenceKind" + } + }, + "allOf": [ + { + "if": { + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "Documentary" + } + } + }, + "then": { + "required": [ + "source" + ], + "properties": { + "qualifiable": { + "const": false + }, + "qualificationStatus": { + "const": "NotExecuted" + }, + "host": false, + "lua": false, + "runtime": false, + "registry": false, + "qualifiedBackends": false, + "authorizedTargets": false, + "sdkPluginContractVersion": false + } + } + }, + { + "if": { + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "Qualifiable" + } + } + }, + "then": { + "required": [ + "host", + "lua", + "runtime", + "registry", + "qualifiedBackends", + "authorizedTargets", + "sdkPluginContractVersion" + ], + "properties": { + "qualifiable": { + "const": true + }, + "source": false + } + } + } + ] + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "commit" + ], + "properties": { + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$" + }, + "commit": { + "$ref": "#/$defs/gitObjectId" + } + } + }, + "host": { + "type": "object", + "additionalProperties": false, + "required": [ + "product", + "version", + "exeName", + "exeSha256", + "machine", + "excludedVariants" + ], + "properties": { + "product": { + "$ref": "#/$defs/text" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+\\.\\d+$" + }, + "exeName": { + "type": "string", + "pattern": "^[^\\\\/:]+\\.exe$" + }, + "exeSha256": { + "$ref": "#/$defs/sha256" + }, + "machine": { + "enum": [ + "AMD64", + "I386", + "ARM64" + ] + }, + "excludedVariants": { + "type": "array", + "items": { + "$ref": "#/$defs/excludedVariant" + } + } + } + }, + "excludedVariant": { + "type": "object", + "additionalProperties": false, + "required": [ + "exeName", + "sha256", + "reason" + ], + "properties": { + "exeName": { + "type": "string", + "pattern": "^[^\\\\/:]+\\.exe$" + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "reason": { + "$ref": "#/$defs/text" + } + } + }, + "lua": { + "type": "object", + "additionalProperties": false, + "required": [ + "module", + "sha256" + ], + "properties": { + "module": { + "type": "string", + "pattern": "^[^\\\\/:]+\\.dll$" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "celua": { + "type": "object", + "additionalProperties": false, + "required": [ + "sha256" + ], + "properties": { + "sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": [ + "loadProfile", + "runtimeconfig", + "dotnetRuntimesObserved" + ], + "properties": { + "loadProfile": { + "const": "managed-hostfxr" + }, + "runtimeconfig": { + "$ref": "#/$defs/runtimeconfig" + }, + "dotnetRuntimesObserved": { + "type": "array", + "items": { + "$ref": "#/$defs/dotnetRuntime" + } + } + } + }, + "runtimeconfig": { + "type": "object", + "additionalProperties": false, + "required": [ + "sha256", + "classification", + "observedDate", + "tfm", + "frameworks" + ], + "properties": { + "sha256": { + "$ref": "#/$defs/sha256" + }, + "classification": { + "enum": [ + "LocalModified", + "Installer", + "Unknown" + ] + }, + "observedDate": { + "$ref": "#/$defs/date" + }, + "tfm": { + "type": "string", + "pattern": "^net\\d+\\.\\d+$" + }, + "frameworks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/framework" + } + } + } + }, + "framework": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version", + "rollForward" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^Microsoft\\.[A-Za-z.]+$" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "rollForward": { + "enum": [ + "Disable", + "LatestPatch", + "Minor", + "LatestMinor", + "Major", + "LatestMajor" + ] + } + } + }, + "dotnetRuntime": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version", + "architecture" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^Microsoft\\.[A-Za-z.]+$" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "architecture": { + "enum": [ + "x64", + "x86", + "arm64" + ] + } + } + }, + "registry": { + "type": "object", + "additionalProperties": false, + "required": [ + "key", + "sharedAcrossCopies", + "profileRelevantValues" + ], + "properties": { + "key": { + "const": "HKCU\\Software\\Cheat Engine" + }, + "sharedAcrossCopies": { + "const": true + }, + "profileRelevantValues": { + "type": "array", + "items": { + "$ref": "#/$defs/registryName" + } + } + } + }, + "registryName": { + "description": "A registry subkey or value name only. Registry data is private and never recorded.", + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "name", + "reason" + ], + "properties": { + "kind": { + "enum": [ + "Subkey", + "Value" + ] + }, + "name": { + "$ref": "#/$defs/text" + }, + "reason": { + "$ref": "#/$defs/text" + } + } + }, + "authorizedTarget": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "arch", + "sha256", + "source" + ], + "properties": { + "kind": { + "enum": [ + "QualificationTarget", + "GtutorialI386" + ] + }, + "arch": { + "enum": [ + "x64", + "x86" + ] + }, + "sha256": { + "anyOf": [ + { + "$ref": "#/$defs/sha256" + }, + { + "type": "null" + } + ] + }, + "source": { + "$ref": "#/$defs/text" + } + } + }, + "decision": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "date", + "text", + "evidenceKind" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^CPA-\\d+$" + }, + "date": { + "$ref": "#/$defs/date" + }, + "text": { + "$ref": "#/$defs/text" + }, + "evidenceKind": { + "$ref": "#/$defs/evidenceKind" + } + } + }, + "unsupportedRoute": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "reason" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "reason": { + "$ref": "#/$defs/text" + } + } + }, + "measurement": { + "type": "object", + "additionalProperties": false, + "required": [ + "subject", + "sha256", + "date", + "method", + "evidenceKind", + "declaredIn" + ], + "properties": { + "subject": { + "$ref": "#/$defs/text" + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "date": { + "$ref": "#/$defs/date" + }, + "method": { + "$ref": "#/$defs/text" + }, + "evidenceKind": { + "$ref": "#/$defs/evidenceKind" + }, + "declaredIn": { + "description": "Repository-relative files that declare the same value before this measurement; empty when nothing declared it.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*(:\\d+(-\\d+)?)?$" + } + } + } + } + } +} From 1a93302a9b1412807db44dffe0d495b1d35610f2 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:38:48 +0200 Subject: [PATCH 022/199] Validate qualification documents with a C# schema subset Repository rules are C# tests, never script-only gates, and Repository.Tests takes no package. Add a validator for exactly the JSON Schema 2020-12 keywords the v0 schemas use (type, const, enum, pattern, required, properties, additionalProperties, items, minItems, minimum, allOf, anyOf, if/then/else, local $ref, boolean schemas), with ECMA-262 patterns, plus the semantic rules a schema cannot express: matrix structure and sub-row aggregation, profile citations, evidence kinds, receipt resolution and freshness, Automated evidence resolved to traited methods of solution test modules, receipt identity and preflight, event-log redaction, and the LF-normalized hash rule. QualificationSchemaTests pins the schemas: draft 2020-12, repository $id, closed objects, only implemented keywords, and every required and enum list equal to the validator constants, so schema and validator cannot drift. A self-test proves the validator reports each violation kind. --- .../Qualification/QualificationSchemaTests.cs | 187 ++++++++ .../Validation/JsonSchemaSubset.cs | 398 +++++++++++++++++ .../Validation/QualificationContract.cs | 205 +++++++++ .../Validation/QualificationDocuments.cs | 163 +++++++ .../Validation/QualificationMatrix.cs | 206 +++++++++ .../Validation/QualificationRules.cs | 399 ++++++++++++++++++ .../Qualification/Validation/ReceiptRules.cs | 178 ++++++++ .../Validation/SupportProfileRules.cs | 81 ++++ .../Validation/TestSourceIndex.cs | 205 +++++++++ .../Qualification/Validation/TextRules.cs | 88 ++++ 10 files changed, 2110 insertions(+) create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationSchemaTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/JsonSchemaSubset.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationContract.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationDocuments.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationMatrix.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationRules.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/ReceiptRules.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/SupportProfileRules.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TestSourceIndex.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TextRules.cs diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationSchemaTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationSchemaTests.cs new file mode 100644 index 00000000..bc448f16 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationSchemaTests.cs @@ -0,0 +1,187 @@ +using System.Text.Json; + +using CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +namespace CheatEngine.SDK.Repository.Tests.Qualification; + +/// +/// The four v0 schemas under docs/qualification/schemas are draft 2020-12, closed, identified by their +/// repository URL, use only the keywords the C# validator implements, and list exactly the validator's vocabulary. +/// +public sealed class QualificationSchemaTests +{ + private const string Draft202012 = "https://json-schema.org/draft/2020-12/schema"; + + public static TheoryData SchemaFiles() + { + return [.. QualificationContract.SchemaFiles]; + } + + [Fact] + public void Every_schema_is_draft_2020_12_with_a_repository_id_and_closed_objects() + { + List problems = []; + foreach (string file in QualificationContract.SchemaFiles) + { + JsonSchemaSubset schema = QualificationDocuments.Schema(file); + JsonElement root = schema.Root; + if (!string.Equals(root.GetProperty("$schema").GetString(), Draft202012, StringComparison.Ordinal)) + { + problems.Add($"{file}: $schema must be {Draft202012}."); + } + + if (!string.Equals(root.GetProperty("$id").GetString(), QualificationContract.SchemaIdPrefix + file, + StringComparison.Ordinal)) + { + problems.Add($"{file}: $id must be {QualificationContract.SchemaIdPrefix + file}."); + } + + string description = root.GetProperty("description").GetString() ?? string.Empty; + if (!description.Contains("CRLF is normalized to LF", StringComparison.Ordinal)) + { + problems.Add($"{file}: the description must state the LF-normalized hash rule (A-SQUAL-4)."); + } + + foreach ((string pointer, JsonElement node) in schema.EnumerateSchemaObjects()) + { + bool isObjectType = node.TryGetProperty("type", out JsonElement type) && + type.GetRawText().Contains("\"object\"", StringComparison.Ordinal); + if (isObjectType && (!node.TryGetProperty("additionalProperties", out JsonElement additional) || + additional.ValueKind != JsonValueKind.False)) + { + problems.Add($"{file}{pointer}: an object schema must set \"additionalProperties\": false."); + } + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + public void Schemas_use_only_the_keywords_the_validator_implements() + { + List problems = []; + foreach (string file in QualificationContract.SchemaFiles) + { + foreach ((string pointer, JsonElement node) in QualificationDocuments.Schema(file).EnumerateSchemaObjects()) + { + foreach (JsonProperty keyword in node.EnumerateObject()) + { + if (!JsonSchemaSubset.SupportedKeywords.Contains(keyword.Name)) + { + problems.Add($"{file}{pointer}: '{keyword.Name}' is not implemented by JsonSchemaSubset."); + } + } + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Theory] + [MemberData(nameof(SchemaFiles))] + public void Schema_required_and_enum_lists_equal_the_validator_constants(string file) + { + List problems = []; + HashSet found = new(StringComparer.Ordinal); + foreach ((string pointer, JsonElement node) in QualificationDocuments.Schema(file).EnumerateSchemaObjects()) + { + // Lists inside an if condition select a branch; they are not requirements of the document. + if (pointer.Contains("/if", StringComparison.Ordinal)) + { + continue; + } + + foreach (string keyword in (string[]) ["required", "enum"]) + { + if (!node.TryGetProperty(keyword, out JsonElement list)) + { + continue; + } + + string key = QualificationContract.Key(file, pointer.Length == 0 ? "/" : pointer, keyword); + found.Add(key); + string[] actual = [.. list.EnumerateArray().Select(static value => value.GetString() ?? string.Empty)]; + if (!QualificationContract.SchemaLists.TryGetValue(key, out string[]? expected)) + { + problems.Add($"{key}: [{string.Join(", ", actual)}] has no validator constant."); + } + else if (!expected.SequenceEqual(actual, StringComparer.Ordinal)) + { + problems.Add($"{key}: schema [{string.Join(", ", actual)}] differs from the validator [{string.Join(", ", expected)}]."); + } + } + } + + foreach (string key in QualificationContract.SchemaLists.Keys) + { + if (key.StartsWith(file + "|", StringComparison.Ordinal) && !found.Contains(key)) + { + problems.Add($"{key}: the validator constant names a list the schema no longer has."); + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + public void Each_schema_pins_its_document_kind_identifier() + { + Assert.Equal(QualificationContract.SupportProfileSchema, SchemaConst(QualificationContract.SupportProfileSchemaFile)); + Assert.Equal(QualificationContract.MatrixSchema, SchemaConst(QualificationContract.MatrixSchemaFile)); + Assert.Equal(QualificationContract.ReceiptSchema, SchemaConst(QualificationContract.ReceiptSchemaFile)); + Assert.Equal(QualificationContract.EventsSchema, SchemaConst(QualificationContract.EventsSchemaFile)); + } + + [Fact] + public void The_validator_reports_unknown_properties_missing_fields_wrong_types_and_failed_conditions() + { + JsonSchemaSubset schema = JsonSchemaSubset.Parse( + """ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "count"], + "properties": { + "status": { "enum": ["Passed", "NotExecuted"] }, + "count": { "type": "integer", "minimum": 1 }, + "hash": { "anyOf": [ { "type": "string", "pattern": "^[0-9a-f]{4}$" }, { "type": "null" } ] }, + "evidence": { "type": "array", "minItems": 1, "items": { "type": "string" } } + }, + "if": { "required": ["status"], "properties": { "status": { "const": "Passed" } } }, + "then": { "required": ["evidence"] }, + "else": { "properties": { "evidence": false } } + } + """, "inline"); + + Assert.Empty(schema.Validate(Parse("""{ "status": "Passed", "count": 2, "hash": "00ff", "evidence": ["x"] }"""))); + Assert.Empty(schema.Validate(Parse("""{ "status": "NotExecuted", "count": 1, "hash": null }"""))); + Assert.Contains(schema.Validate(Parse("""{ "status": "Passed", "count": 1, "extra": 1, "evidence": ["x"] }""")), + static error => error.Contains("unexpected property 'extra'", StringComparison.Ordinal)); + Assert.Contains(schema.Validate(Parse("""{ "status": "Passed", "evidence": ["x"] }""")), + static error => error.Contains("required property 'count'", StringComparison.Ordinal)); + Assert.Contains(schema.Validate(Parse("""{ "status": "Passed", "count": 1.5, "evidence": ["x"] }""")), + static error => error.Contains("must be of type", StringComparison.Ordinal)); + Assert.Contains(schema.Validate(Parse("""{ "status": "Passed", "count": 0, "evidence": ["x"] }""")), + static error => error.Contains("at least 1", StringComparison.Ordinal)); + Assert.Contains(schema.Validate(Parse("""{ "status": "Passed", "count": 1 }""")), + static error => error.Contains("required property 'evidence'", StringComparison.Ordinal)); + Assert.Contains(schema.Validate(Parse("""{ "status": "NotExecuted", "count": 1, "evidence": ["x"] }""")), + static error => error.Contains("not allowed", StringComparison.Ordinal)); + Assert.Contains(schema.Validate(Parse("""{ "status": "Failed", "count": 1 }""")), + static error => error.Contains("must be one of", StringComparison.Ordinal)); + Assert.Contains(schema.Validate(Parse("""{ "status": "NotExecuted", "count": 1, "hash": "00FF" }""")), + static error => error.Contains("anyOf", StringComparison.Ordinal)); + } + + private static string? SchemaConst(string file) + { + return QualificationDocuments.Schema(file).Root.GetProperty("properties").GetProperty("schema") + .GetProperty("const").GetString(); + } + + private static JsonElement Parse(string json) + { + return QualificationDocuments.ParseJson(json); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/JsonSchemaSubset.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/JsonSchemaSubset.cs new file mode 100644 index 00000000..f3a1e8b4 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/JsonSchemaSubset.cs @@ -0,0 +1,398 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// +/// A validator for the subset of JSON Schema draft 2020-12 the qualification schemas use. It deliberately implements +/// only : QualificationSchemaTests fails when a schema uses anything else, so a +/// schema can never rely on a keyword that this validator would silently ignore. +/// +/// +/// Semantics follow https://json-schema.org/draft/2020-12/json-schema-validation: pattern is an ECMA-262 +/// regular expression (evaluated with , unanchored), properties, +/// required and additionalProperties apply to objects only, items and minItems to arrays only, +/// minimum to numbers only, and $ref resolves local #/$defs/… pointers only. +/// +internal sealed class JsonSchemaSubset +{ + /// Keywords with validation semantics implemented below, plus pure annotations. + internal static readonly IReadOnlySet SupportedKeywords = new HashSet(StringComparer.Ordinal) + { + "$schema", "$id", "$comment", "$defs", "$ref", "title", "description", + "type", "const", "enum", "pattern", "required", "properties", "additionalProperties", "items", "minItems", + "minimum", "allOf", "anyOf", "if", "then", "else" + }; + + private const string DefinitionsPrefix = "#/$defs/"; + private static readonly TimeSpan PatternTimeout = TimeSpan.FromSeconds(1); + + private readonly Dictionary _patterns = new(StringComparer.Ordinal); + + private JsonSchemaSubset(JsonElement root, string name) + { + Root = root; + Name = name; + } + + /// The root schema object (a standalone clone, independent of any document). + internal JsonElement Root + { + get; + } + + /// The schema file name, for messages. + internal string Name + { + get; + } + + /// Loads a schema from its UTF-8 text. + internal static JsonSchemaSubset Parse(string json, string name) + { + using JsonDocument document = JsonDocument.Parse(json); + return new JsonSchemaSubset(document.RootElement.Clone(), name); + } + + /// Validates ; returns one message per violation, empty when valid. + internal IReadOnlyList Validate(JsonElement instance) + { + List errors = []; + Validate(Root, instance, string.Empty, errors); + return errors; + } + + /// Returns whether is valid. + internal bool IsValid(JsonElement instance) + { + return Validate(instance).Count == 0; + } + + /// Enumerates every schema object of the document with its JSON pointer, depth first. + internal IEnumerable<(string Pointer, JsonElement Schema)> EnumerateSchemaObjects() + { + return EnumerateSchemaObjects(Root, string.Empty); + } + + private static IEnumerable<(string Pointer, JsonElement Schema)> EnumerateSchemaObjects(JsonElement schema, + string pointer) + { + if (schema.ValueKind != JsonValueKind.Object) + { + yield break; + } + + yield return (pointer, schema); + foreach (JsonProperty keyword in schema.EnumerateObject()) + { + string childPointer = pointer + "/" + keyword.Name; + switch (keyword.Name) + { + case "properties": + case "$defs": + foreach (JsonProperty entry in keyword.Value.EnumerateObject()) + { + foreach ((string Pointer, JsonElement Schema) nested in EnumerateSchemaObjects(entry.Value, + childPointer + "/" + entry.Name)) + { + yield return nested; + } + } + + break; + case "allOf": + case "anyOf": + int index = 0; + foreach (JsonElement branch in keyword.Value.EnumerateArray()) + { + foreach ((string Pointer, JsonElement Schema) nested in EnumerateSchemaObjects(branch, + childPointer + "/" + index.ToString(CultureInfo.InvariantCulture))) + { + yield return nested; + } + + index++; + } + + break; + case "items": + case "additionalProperties": + case "if": + case "then": + case "else": + foreach ((string Pointer, JsonElement Schema) nested in EnumerateSchemaObjects(keyword.Value, + childPointer)) + { + yield return nested; + } + + break; + } + } + } + + private void Validate(JsonElement schema, JsonElement instance, string pointer, List errors) + { + switch (schema.ValueKind) + { + case JsonValueKind.True: + return; + case JsonValueKind.False: + errors.Add(At(pointer, "is not allowed here")); + return; + case JsonValueKind.Object: + break; + default: + throw new InvalidOperationException($"{Name}: a schema must be an object or a boolean at '{pointer}'."); + } + + ValidateReference(schema, instance, pointer, errors); + ValidateTypeConstAndEnum(schema, instance, pointer, errors); + ValidateString(schema, instance, pointer, errors); + ValidateNumber(schema, instance, pointer, errors); + ValidateObject(schema, instance, pointer, errors); + ValidateArray(schema, instance, pointer, errors); + ValidateCombinators(schema, instance, pointer, errors); + } + + private void ValidateReference(JsonElement schema, JsonElement instance, string pointer, List errors) + { + if (schema.TryGetProperty("$ref", out JsonElement reference)) + { + Validate(Resolve(reference.GetString() ?? string.Empty), instance, pointer, errors); + } + } + + private static void ValidateTypeConstAndEnum(JsonElement schema, JsonElement instance, string pointer, + List errors) + { + if (schema.TryGetProperty("type", out JsonElement type) && !MatchesType(type, instance)) + { + errors.Add(At(pointer, $"must be of type {type.GetRawText()}, found {instance.ValueKind}")); + } + + if (schema.TryGetProperty("const", out JsonElement constant) && !JsonElement.DeepEquals(constant, instance)) + { + errors.Add(At(pointer, $"must equal {constant.GetRawText()}, found {Describe(instance)}")); + } + + if (schema.TryGetProperty("enum", out JsonElement values)) + { + foreach (JsonElement value in values.EnumerateArray()) + { + if (JsonElement.DeepEquals(value, instance)) + { + return; + } + } + + errors.Add(At(pointer, $"must be one of {values.GetRawText()}, found {Describe(instance)}")); + } + } + + private void ValidateString(JsonElement schema, JsonElement instance, string pointer, List errors) + { + if (instance.ValueKind != JsonValueKind.String || !schema.TryGetProperty("pattern", out JsonElement pattern)) + { + return; + } + + string expression = pattern.GetString() ?? string.Empty; + if (!_patterns.TryGetValue(expression, out Regex? regex)) + { + regex = new Regex(expression, RegexOptions.ECMAScript, PatternTimeout); + _patterns.Add(expression, regex); + } + + if (!regex.IsMatch(instance.GetString() ?? string.Empty)) + { + errors.Add(At(pointer, $"does not match the pattern {expression}: {Describe(instance)}")); + } + } + + private static void ValidateNumber(JsonElement schema, JsonElement instance, string pointer, List errors) + { + if (instance.ValueKind == JsonValueKind.Number && schema.TryGetProperty("minimum", out JsonElement minimum) && + instance.GetDecimal() < minimum.GetDecimal()) + { + errors.Add(At(pointer, $"must be at least {minimum.GetRawText()}, found {instance.GetRawText()}")); + } + } + + private void ValidateObject(JsonElement schema, JsonElement instance, string pointer, List errors) + { + if (instance.ValueKind != JsonValueKind.Object) + { + return; + } + + if (schema.TryGetProperty("required", out JsonElement required)) + { + foreach (JsonElement name in required.EnumerateArray()) + { + if (!instance.TryGetProperty(name.GetString() ?? string.Empty, out _)) + { + errors.Add(At(pointer, $"misses the required property '{name.GetString()}'")); + } + } + } + + bool hasProperties = schema.TryGetProperty("properties", out JsonElement properties); + bool hasAdditional = schema.TryGetProperty("additionalProperties", out JsonElement additional); + foreach (JsonProperty property in instance.EnumerateObject()) + { + string childPointer = pointer + "/" + EscapePointer(property.Name); + if (hasProperties && properties.TryGetProperty(property.Name, out JsonElement propertySchema)) + { + Validate(propertySchema, property.Value, childPointer, errors); + } + else if (hasAdditional) + { + if (additional.ValueKind == JsonValueKind.False) + { + errors.Add(At(pointer, $"has the unexpected property '{property.Name}'")); + } + else + { + Validate(additional, property.Value, childPointer, errors); + } + } + } + } + + private void ValidateArray(JsonElement schema, JsonElement instance, string pointer, List errors) + { + if (instance.ValueKind != JsonValueKind.Array) + { + return; + } + + if (schema.TryGetProperty("minItems", out JsonElement minItems) && + instance.GetArrayLength() < minItems.GetInt32()) + { + errors.Add(At(pointer, $"must have at least {minItems.GetInt32()} item(s), found {instance.GetArrayLength()}")); + } + + if (schema.TryGetProperty("items", out JsonElement items)) + { + int index = 0; + foreach (JsonElement item in instance.EnumerateArray()) + { + Validate(items, item, pointer + "/" + index.ToString(CultureInfo.InvariantCulture), errors); + index++; + } + } + } + + private void ValidateCombinators(JsonElement schema, JsonElement instance, string pointer, List errors) + { + if (schema.TryGetProperty("allOf", out JsonElement allOf)) + { + foreach (JsonElement branch in allOf.EnumerateArray()) + { + Validate(branch, instance, pointer, errors); + } + } + + if (schema.TryGetProperty("anyOf", out JsonElement anyOf)) + { + List firstBranchErrors = []; + bool matched = false; + foreach (JsonElement branch in anyOf.EnumerateArray()) + { + List branchErrors = []; + Validate(branch, instance, pointer, branchErrors); + if (branchErrors.Count == 0) + { + matched = true; + break; + } + + if (firstBranchErrors.Count == 0) + { + firstBranchErrors = branchErrors; + } + } + + if (!matched) + { + errors.Add(At(pointer, "matches no anyOf branch; first branch: " + string.Join("; ", firstBranchErrors))); + } + } + + if (schema.TryGetProperty("if", out JsonElement condition)) + { + List conditionErrors = []; + Validate(condition, instance, pointer, conditionErrors); + string branchName = conditionErrors.Count == 0 ? "then" : "else"; + if (schema.TryGetProperty(branchName, out JsonElement branch)) + { + Validate(branch, instance, pointer, errors); + } + } + } + + private JsonElement Resolve(string reference) + { + if (!reference.StartsWith(DefinitionsPrefix, StringComparison.Ordinal) || + !Root.TryGetProperty("$defs", out JsonElement definitions) || + !definitions.TryGetProperty(reference[DefinitionsPrefix.Length..], out JsonElement target)) + { + throw new InvalidOperationException( + $"{Name}: only local '{DefinitionsPrefix}name' references are supported; '{reference}' does not resolve."); + } + + return target; + } + + private static bool MatchesType(JsonElement type, JsonElement instance) + { + if (type.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement candidate in type.EnumerateArray()) + { + if (MatchesType(candidate.GetString() ?? string.Empty, instance)) + { + return true; + } + } + + return false; + } + + return MatchesType(type.GetString() ?? string.Empty, instance); + } + + private static bool MatchesType(string type, JsonElement instance) + { + return type switch + { + "object" => instance.ValueKind == JsonValueKind.Object, + "array" => instance.ValueKind == JsonValueKind.Array, + "string" => instance.ValueKind == JsonValueKind.String, + "boolean" => instance.ValueKind is JsonValueKind.True or JsonValueKind.False, + "null" => instance.ValueKind == JsonValueKind.Null, + "number" => instance.ValueKind == JsonValueKind.Number, + "integer" => instance.ValueKind == JsonValueKind.Number && instance.TryGetDecimal(out decimal value) && + decimal.Truncate(value) == value, + _ => throw new InvalidOperationException($"Unknown JSON Schema type '{type}'.") + }; + } + + private static string Describe(JsonElement instance) + { + string raw = instance.GetRawText(); + return raw.Length <= 80 ? raw : raw[..77] + "..."; + } + + private static string EscapePointer(string name) + { + return name.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal); + } + + private static string At(string pointer, string message) + { + return (pointer.Length == 0 ? "/" : pointer) + " " + message; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationContract.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationContract.cs new file mode 100644 index 00000000..a8fce50d --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationContract.cs @@ -0,0 +1,205 @@ +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// +/// The frozen v0 vocabulary of the qualification documents (shared-contracts sections 2.0 to 2.3 with the ratified +/// A-SQUAL amendments). The semantic rules use these constants, and +/// QualificationSchemaTests.Schema_required_and_enum_lists_equal_the_validator_constants proves that every +/// required and enum list of the committed schemas equals them, so the schemas and the validator cannot +/// drift apart. +/// +internal static class QualificationContract +{ + internal const string SupportProfileSchema = "cheatengine-support-profile/v0"; + internal const string MatrixSchema = "cheatengine-qualification-matrix/v0"; + internal const string ReceiptSchema = "cheatengine-qualification-receipt/v0"; + internal const string EventsSchema = "cheatengine-qualification-events/v0"; + + internal const string SupportProfileSchemaFile = "support-profile.v0.schema.json"; + internal const string MatrixSchemaFile = "qualification-matrix.v0.schema.json"; + internal const string ReceiptSchemaFile = "qualification-receipt.v0.schema.json"; + internal const string EventsSchemaFile = "qualification-events.v0.schema.json"; + + internal const string DocumentaryProfileId = "ce-public-src-ec45d5f"; + internal const string QualifiableProfileId = "ce-7.7.0.10621-x64-managed-hostfxr"; + internal const string Repository = "CheatEngineNet/CheatEngine.SDK"; + internal const string RunnerScript = "eng/qualification/Invoke-LocalQualification.ps1"; + + /// SHA-256 of the audit dossier's MANIFESTE.md: its identity, never a local path. + internal const string AuditManifestSha256 = "7179b0691d27cba0589b3f5fa00945ddbb7c04b362500df3de30726550f4ff6e"; + + internal const string SchemaIdPrefix = + "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/docs/qualification/schemas/"; + + internal static readonly string[] SchemaFiles = + [SupportProfileSchemaFile, MatrixSchemaFile, ReceiptSchemaFile, EventsSchemaFile]; + + internal static readonly string[] Statuses = ["NotExecuted", "Passed", "Failed", "NotApplicable"]; + internal static readonly string[] ReceiptStatuses = ["Passed", "Failed", "NotApplicable"]; + internal static readonly string[] PassKinds = ["Functional", "RefusalVerified"]; + internal static readonly string[] Levels = ["C0", "C1", "C2", "C3", "C4"]; + internal static readonly string[] HostLevels = ["C3", "C4"]; + + internal static readonly string[] EvidenceKinds = + [ + "ObservedSource", "DeclaredRepo", "Deduced", "ToQualify", "ProposedDecision", "ObservedHost", "ExactBinary", + "ExactInstalledFile", "PinnedUpstream", "ObservedLive", "Inferred", "Unknown" + ]; + + internal static readonly string[] ExpectedCategories = ["Effect", "Partial", "Refused", "Unknown"]; + internal static readonly string[] Owners = ["SDK", "Client", "Both"]; + internal static readonly string[] Repositories = ["CheatEngineNet/CheatEngine.SDK", "CheatEngineNet/CheatEngine.Client"]; + internal static readonly string[] ProfileKinds = ["Documentary", "Qualifiable"]; + internal static readonly string[] ProfileQualificationStatuses = ["NotExecuted", "FixtureQualified", "HostQualified"]; + internal static readonly string[] Backends = ["LocalProcess", "FileAsProcess", "CEServer"]; + internal static readonly string[] Machines = ["AMD64", "I386", "ARM64"]; + internal static readonly string[] RuntimeconfigClassifications = ["LocalModified", "Installer", "Unknown"]; + + internal static readonly string[] RollForwardPolicies = + ["Disable", "LatestPatch", "Minor", "LatestMinor", "Major", "LatestMajor"]; + + internal static readonly string[] DotnetArchitectures = ["x64", "x86", "arm64"]; + internal static readonly string[] RegistryNameKinds = ["Subkey", "Value"]; + internal static readonly string[] AuthorizedTargetKinds = ["QualificationTarget", "GtutorialI386"]; + internal static readonly string[] TargetKinds = ["QualificationTarget", "GtutorialI386", "None"]; + internal static readonly string[] TargetArchitectures = ["x64", "x86"]; + internal static readonly string[] LoadRoutes = ["LuaLoadPlugin", "SettingsPluginsUi", "None"]; + internal static readonly string[] PackageSources = ["CiArtifact", "NuGetOrg"]; + + internal static readonly string[] BundleNames = + ["LiveProbe", "LiveProbeNonAscii", "LivePlugin", "CoexistenceA", "CoexistenceB", "CoexistenceShared"]; + + internal static readonly string[] EventSources = ["Runner", "Driver", "Plugin", "Operator"]; + + /// + /// Every required and enum list of the four schemas outside if conditions, keyed by + /// file|JSON pointer|keyword. + /// + internal static readonly IReadOnlyDictionary SchemaLists = + new Dictionary(StringComparer.Ordinal) + { + // support-profile.v0.schema.json + [Key(SupportProfileSchemaFile, "/", "required")] = ["schema", "profiles", "decisions", "unsupportedRoutes"], + [Key(SupportProfileSchemaFile, "/$defs/evidenceKind", "enum")] = EvidenceKinds, + [Key(SupportProfileSchemaFile, "/$defs/profile", "required")] = + ["id", "kind", "qualifiable", "qualificationStatus", "description", "celua", "evidenceKind"], + [Key(SupportProfileSchemaFile, "/$defs/profile/properties/kind", "enum")] = ProfileKinds, + [Key(SupportProfileSchemaFile, "/$defs/profile/properties/qualificationStatus", "enum")] = + ProfileQualificationStatuses, + [Key(SupportProfileSchemaFile, "/$defs/profile/properties/qualifiedBackends/items", "enum")] = Backends, + [Key(SupportProfileSchemaFile, "/$defs/profile/allOf/0/then", "required")] = ["source"], + [Key(SupportProfileSchemaFile, "/$defs/profile/allOf/1/then", "required")] = + [ + "host", "lua", "runtime", "registry", "qualifiedBackends", "authorizedTargets", + "sdkPluginContractVersion" + ], + [Key(SupportProfileSchemaFile, "/$defs/source", "required")] = ["repository", "commit"], + [Key(SupportProfileSchemaFile, "/$defs/host", "required")] = + ["product", "version", "exeName", "exeSha256", "machine", "excludedVariants"], + [Key(SupportProfileSchemaFile, "/$defs/host/properties/machine", "enum")] = Machines, + [Key(SupportProfileSchemaFile, "/$defs/excludedVariant", "required")] = ["exeName", "sha256", "reason"], + [Key(SupportProfileSchemaFile, "/$defs/lua", "required")] = ["module", "sha256"], + [Key(SupportProfileSchemaFile, "/$defs/celua", "required")] = ["sha256"], + [Key(SupportProfileSchemaFile, "/$defs/runtime", "required")] = + ["loadProfile", "runtimeconfig", "dotnetRuntimesObserved"], + [Key(SupportProfileSchemaFile, "/$defs/runtimeconfig", "required")] = + ["sha256", "classification", "observedDate", "tfm", "frameworks"], + [Key(SupportProfileSchemaFile, "/$defs/runtimeconfig/properties/classification", "enum")] = + RuntimeconfigClassifications, + [Key(SupportProfileSchemaFile, "/$defs/framework", "required")] = ["name", "version", "rollForward"], + [Key(SupportProfileSchemaFile, "/$defs/framework/properties/rollForward", "enum")] = RollForwardPolicies, + [Key(SupportProfileSchemaFile, "/$defs/dotnetRuntime", "required")] = ["name", "version", "architecture"], + [Key(SupportProfileSchemaFile, "/$defs/dotnetRuntime/properties/architecture", "enum")] = + DotnetArchitectures, + [Key(SupportProfileSchemaFile, "/$defs/registry", "required")] = + ["key", "sharedAcrossCopies", "profileRelevantValues"], + [Key(SupportProfileSchemaFile, "/$defs/registryName", "required")] = ["kind", "name", "reason"], + [Key(SupportProfileSchemaFile, "/$defs/registryName/properties/kind", "enum")] = RegistryNameKinds, + [Key(SupportProfileSchemaFile, "/$defs/authorizedTarget", "required")] = ["kind", "arch", "sha256", "source"], + [Key(SupportProfileSchemaFile, "/$defs/authorizedTarget/properties/kind", "enum")] = AuthorizedTargetKinds, + [Key(SupportProfileSchemaFile, "/$defs/authorizedTarget/properties/arch", "enum")] = TargetArchitectures, + [Key(SupportProfileSchemaFile, "/$defs/decision", "required")] = ["id", "date", "text", "evidenceKind"], + [Key(SupportProfileSchemaFile, "/$defs/unsupportedRoute", "required")] = ["id", "reason"], + [Key(SupportProfileSchemaFile, "/$defs/measurement", "required")] = + ["subject", "sha256", "date", "method", "evidenceKind", "declaredIn"], + + // qualification-matrix.v0.schema.json + [Key(MatrixSchemaFile, "/", "required")] = ["schema", "repository", "audit", "profiles", "rows"], + [Key(MatrixSchemaFile, "/properties/repository", "enum")] = Repositories, + [Key(MatrixSchemaFile, "/properties/audit", "required")] = ["manifestSha256"], + [Key(MatrixSchemaFile, "/$defs/level", "enum")] = Levels, + [Key(MatrixSchemaFile, "/$defs/status", "enum")] = Statuses, + [Key(MatrixSchemaFile, "/$defs/passKind", "enum")] = PassKinds, + [Key(MatrixSchemaFile, "/$defs/evidenceKind", "enum")] = EvidenceKinds, + [Key(MatrixSchemaFile, "/$defs/row", "required")] = + [ + "id", "parent", "title", "titleFr", "owner", "requiredLevels", "blocks", "audit", "scenario", + "levels" + ], + [Key(MatrixSchemaFile, "/$defs/row/properties/owner", "enum")] = Owners, + [Key(MatrixSchemaFile, "/$defs/auditReference", "required")] = ["ref", "findings"], + [Key(MatrixSchemaFile, "/$defs/scenario", "required")] = + ["preconditions", "operation", "expected", "expectedCategory"], + [Key(MatrixSchemaFile, "/$defs/scenario/properties/expectedCategory", "enum")] = ExpectedCategories, + [Key(MatrixSchemaFile, "/$defs/fixtureCell", "required")] = ["status", "evidenceKind"], + [Key(MatrixSchemaFile, "/$defs/hostCell", "required")] = ["status", "evidenceKind", "profileId"], + [Key(MatrixSchemaFile, "/$defs/hostCell/allOf/1/then", "required")] = ["treeHash", "nupkgSha256"], + [Key(MatrixSchemaFile, "/$defs/statusRules/allOf/0/then", "required")] = ["passKind", "evidence", "date"], + [Key(MatrixSchemaFile, "/$defs/statusRules/allOf/1/then", "required")] = + ["evidence", "date", "justification"], + [Key(MatrixSchemaFile, "/$defs/statusRules/allOf/2/then", "required")] = ["justification"], + [Key(MatrixSchemaFile, "/$defs/automatedEvidence", "required")] = ["kind", "project", "file", "test", "trait"], + [Key(MatrixSchemaFile, "/$defs/ciRunEvidence", "required")] = ["kind", "url"], + [Key(MatrixSchemaFile, "/$defs/receiptEvidence", "required")] = ["kind", "receiptId", "path", "sha256"], + + // qualification-receipt.v0.schema.json + [Key(ReceiptSchemaFile, "/", "required")] = + [ + "schema", "receiptId", "qualificationId", "level", "profileId", "operator", "loadRoute", "repository", + "runner", "package", "host", "bridge", "bundles", "target", "registry", "preconditions", "operation", + "expected", "observed", "status", "evidenceKind", "justification", "timings", "eventLog", + "transferJustification", "createdUtc" + ], + [Key(ReceiptSchemaFile, "/properties/level", "enum")] = HostLevels, + [Key(ReceiptSchemaFile, "/properties/loadRoute", "enum")] = LoadRoutes, + [Key(ReceiptSchemaFile, "/properties/status", "enum")] = ReceiptStatuses, + [Key(ReceiptSchemaFile, "/properties/passKind/anyOf/0", "enum")] = PassKinds, + [Key(ReceiptSchemaFile, "/allOf/0/then", "required")] = ["passKind"], + [Key(ReceiptSchemaFile, "/allOf/0/then/properties/passKind", "enum")] = PassKinds, + [Key(ReceiptSchemaFile, "/$defs/repository", "required")] = ["name", "treeHash", "commit", "pullRequest"], + [Key(ReceiptSchemaFile, "/$defs/repository/properties/name", "enum")] = Repositories, + [Key(ReceiptSchemaFile, "/$defs/pullRequest", "required")] = ["number", "headSha"], + [Key(ReceiptSchemaFile, "/$defs/runner", "required")] = + ["script", "scriptSha256", "sourceRepository", "sourceCommit", "mutex"], + [Key(ReceiptSchemaFile, "/$defs/package", "required")] = + ["id", "version", "nupkgSha256", "contentHashSha512", "source", "ciRunUrl"], + [Key(ReceiptSchemaFile, "/$defs/package/properties/source", "enum")] = PackageSources, + [Key(ReceiptSchemaFile, "/$defs/host", "required")] = + [ + "ceExeName", "ceExeSha256", "ceFileVersion", "luaDllSha256", "runtimeconfigSha256", "autorunSha256", + "sandboxCopy", "osVersion", "dotnetRuntimes" + ], + [Key(ReceiptSchemaFile, "/$defs/bridge", "required")] = ["sha256", "sourceFingerprint"], + [Key(ReceiptSchemaFile, "/$defs/bundle", "required")] = ["name", "manifestSha256", "files"], + [Key(ReceiptSchemaFile, "/$defs/bundle/properties/name", "enum")] = BundleNames, + [Key(ReceiptSchemaFile, "/$defs/bundleFile", "required")] = ["path", "sha256"], + [Key(ReceiptSchemaFile, "/$defs/target", "required")] = ["kind", "arch", "sha256"], + [Key(ReceiptSchemaFile, "/$defs/target/properties/kind", "enum")] = TargetKinds, + [Key(ReceiptSchemaFile, "/$defs/target/properties/arch/anyOf/0", "enum")] = TargetArchitectures, + [Key(ReceiptSchemaFile, "/$defs/registry", "required")] = + ["key", "exportBeforeSha256", "exportAfterSha256", "restored", "diff"], + [Key(ReceiptSchemaFile, "/$defs/registryDiff", "required")] = ["added", "removed", "changed", "valueNames"], + [Key(ReceiptSchemaFile, "/$defs/timings", "required")] = ["startedUtc", "finishedUtc", "durationMs"], + [Key(ReceiptSchemaFile, "/$defs/eventLog", "required")] = ["path", "sha256", "format", "redactions"], + + // qualification-events.v0.schema.json + [Key(EventsSchemaFile, "/", "required")] = ["schema", "receiptId", "events"], + [Key(EventsSchemaFile, "/$defs/event", "required")] = ["tMs", "source", "kind", "message"], + [Key(EventsSchemaFile, "/$defs/event/properties/source", "enum")] = EventSources, + [Key(EventsSchemaFile, "/$defs/summary", "required")] = ["keptFirst", "keptLast", "dropped"] + }; + + internal static string Key(string schemaFile, string pointer, string keyword) + { + return schemaFile + "|" + pointer + "|" + keyword; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationDocuments.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationDocuments.cs new file mode 100644 index 00000000..a4e97564 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationDocuments.cs @@ -0,0 +1,163 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// Reads the committed qualification documents and applies the encoding rules of shared-contracts section 2.0. +internal static class QualificationDocuments +{ + internal const string QualificationDirectory = "docs/qualification"; + internal const string SchemaDirectory = "docs/qualification/schemas"; + internal const string ReceiptDirectory = "docs/qualification/receipts"; + internal const string SupportProfilePath = "docs/qualification/support-profile.json"; + internal const string SupportProfileMarkdownPath = "docs/qualification/support-profile.md"; + internal const string MatrixPath = "docs/qualification/matrix.json"; + internal const string ReadmePath = "docs/qualification/README.md"; + internal const string EventsSuffix = ".events.json"; + + private static readonly JsonWriterOptions CanonicalWriterOptions = new() + { + Indented = true, + IndentSize = 2, + IndentCharacter = ' ', + NewLine = "\n", + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private static readonly Dictionary s_schemas = new(StringComparer.Ordinal); + private static readonly Lock SchemaGate = new(); + + /// Reads a repository file as UTF-8 text with CRLF normalized to LF (the committed blob form). + internal static string ReadNormalizedText(string repositoryRelativePath) + { + return NormalizeLineEndings(File.ReadAllText(Absolute(repositoryRelativePath), Encoding.UTF8)); + } + + /// Replaces CRLF with LF: the working tree is CRLF (.gitattributes), git blobs and clones are LF. + internal static string NormalizeLineEndings(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal); + } + + /// + /// SHA-256, lowercase hex, of a committed JSON document: its UTF-8 bytes after CRLF is normalized to LF, so the + /// value is the same on every checkout (A-SQUAL-4). + /// + internal static string CommittedJsonSha256(string repositoryRelativePath) + { + return Sha256OfNormalizedText(File.ReadAllText(Absolute(repositoryRelativePath), Encoding.UTF8)); + } + + /// The same rule applied to text already in memory. + internal static string Sha256OfNormalizedText(string text) + { + byte[] bytes = Encoding.UTF8.GetBytes(NormalizeLineEndings(text)); + return Convert.ToHexStringLower(SHA256.HashData(bytes)); + } + + /// SHA-256, lowercase hex, of a repository file's raw bytes (binaries and LF-pinned sources). + internal static string RawSha256(string repositoryRelativePath) + { + using FileStream stream = File.OpenRead(Absolute(repositoryRelativePath)); + return Convert.ToHexStringLower(SHA256.HashData(stream)); + } + + /// Parses a committed JSON document into a standalone element. + internal static JsonElement LoadJson(string repositoryRelativePath) + { + return ParseJson(ReadNormalizedText(repositoryRelativePath)); + } + + /// Parses JSON text into a standalone element. + internal static JsonElement ParseJson(string json) + { + using JsonDocument document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + /// Loads (once) the committed schema of the given file name. + internal static JsonSchemaSubset Schema(string schemaFileName) + { + lock (SchemaGate) + { + if (!s_schemas.TryGetValue(schemaFileName, out JsonSchemaSubset? schema)) + { + schema = JsonSchemaSubset.Parse(ReadNormalizedText(SchemaDirectory + "/" + schemaFileName), + schemaFileName); + s_schemas.Add(schemaFileName, schema); + } + + return schema; + } + } + + /// + /// The canonical text of a qualification document: two-space indentation, LF newlines, non-ASCII characters kept + /// readable, property order as written, and one final newline. + /// + internal static string Canonical(JsonElement document) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream, CanonicalWriterOptions)) + { + document.WriteTo(writer); + } + + return Encoding.UTF8.GetString(stream.ToArray()) + "\n"; + } + + /// Committed receipts (never event logs), repository-relative, sorted. + internal static IReadOnlyList CommittedReceipts() + { + List receipts = []; + string directory = Absolute(ReceiptDirectory); + if (!Directory.Exists(directory)) + { + return receipts; + } + + foreach (string file in Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories)) + { + if (!file.EndsWith(EventsSuffix, StringComparison.OrdinalIgnoreCase)) + { + receipts.Add(RepositoryRoot.ToRelative(file)); + } + } + + receipts.Sort(StringComparer.Ordinal); + return receipts; + } + + /// Committed event logs, repository-relative, sorted. + internal static IReadOnlyList CommittedEventLogs() + { + List logs = []; + string directory = Absolute(ReceiptDirectory); + if (!Directory.Exists(directory)) + { + return logs; + } + + foreach (string file in Directory.EnumerateFiles(directory, "*" + EventsSuffix, SearchOption.AllDirectories)) + { + logs.Add(RepositoryRoot.ToRelative(file)); + } + + logs.Sort(StringComparer.Ordinal); + return logs; + } + + internal static bool Exists(string repositoryRelativePath) + { + return File.Exists(Absolute(repositoryRelativePath)); + } + + internal static string Absolute(string repositoryRelativePath) + { + return Path.Combine(RepositoryRoot.Path, repositoryRelativePath.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationMatrix.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationMatrix.cs new file mode 100644 index 00000000..9554a624 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationMatrix.cs @@ -0,0 +1,206 @@ +using System.Text.Json; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// A typed read of a schema-valid matrix.json, used by the semantic rules and the Markdown renderer. +internal sealed class QualificationMatrix +{ + private QualificationMatrix(string repository, IReadOnlyList profiles, IReadOnlyList rows) + { + Repository = repository; + Profiles = profiles; + Rows = rows; + } + + internal string Repository + { + get; + } + + internal IReadOnlyList Profiles + { + get; + } + + internal IReadOnlyList Rows + { + get; + } + + /// Reads a matrix that already passed schema validation. + internal static QualificationMatrix Read(JsonElement matrix) + { + List profiles = []; + foreach (JsonElement profile in matrix.GetProperty("profiles").EnumerateArray()) + { + profiles.Add(profile.GetString()!); + } + + List rows = []; + foreach (JsonElement row in matrix.GetProperty("rows").EnumerateArray()) + { + rows.Add(ReadRow(row)); + } + + return new QualificationMatrix(matrix.GetProperty("repository").GetString()!, profiles, rows); + } + + internal Row? Find(string id) + { + foreach (Row row in Rows) + { + if (string.Equals(row.Id, id, StringComparison.Ordinal)) + { + return row; + } + } + + return null; + } + + /// Every cell with its row, in row order then level order. + internal IEnumerable<(Row Row, Cell Cell)> Cells() + { + foreach (Row row in Rows) + { + foreach (string level in QualificationContract.Levels) + { + if (row.Levels.TryGetValue(level, out Cell? cell)) + { + yield return (row, cell); + } + } + } + } + + private static Row ReadRow(JsonElement row) + { + JsonElement scenario = row.GetProperty("scenario"); + JsonElement audit = row.GetProperty("audit"); + Dictionary levels = new(StringComparer.Ordinal); + foreach (JsonProperty level in row.GetProperty("levels").EnumerateObject()) + { + levels.Add(level.Name, ReadCell(level.Name, level.Value)); + } + + return new Row( + row.GetProperty("id").GetString()!, + row.GetProperty("parent").ValueKind == JsonValueKind.Null ? null : row.GetProperty("parent").GetString(), + Strings(row, "subRows"), + row.GetProperty("title").GetString()!, + row.GetProperty("titleFr").GetString()!, + row.GetProperty("owner").GetString()!, + Strings(row, "requiredLevels"), + row.GetProperty("blocks").GetString()!, + audit.GetProperty("ref").GetString()!, + Strings(audit, "findings"), + Strings(scenario, "preconditions"), + scenario.GetProperty("operation").GetString()!, + scenario.GetProperty("expected").GetString()!, + scenario.GetProperty("expectedCategory").GetString()!, + levels); + } + + private static Cell ReadCell(string level, JsonElement cell) + { + List evidence = []; + if (cell.TryGetProperty("evidence", out JsonElement items)) + { + foreach (JsonElement item in items.EnumerateArray()) + { + evidence.Add(new Evidence( + item.GetProperty("kind").GetString()!, + OptionalString(item, "project"), + OptionalString(item, "file"), + OptionalString(item, "test"), + OptionalString(item, "trait"), + OptionalString(item, "receiptId"), + OptionalString(item, "path"), + OptionalString(item, "sha256"), + OptionalString(item, "url"))); + } + } + + return new Cell( + level, + cell.GetProperty("status").GetString()!, + OptionalString(cell, "passKind"), + cell.GetProperty("evidenceKind").GetString()!, + OptionalString(cell, "profileId"), + evidence, + OptionalString(cell, "justification"), + OptionalString(cell, "expected"), + OptionalString(cell, "treeHash"), + OptionalString(cell, "nupkgSha256"), + OptionalString(cell, "transferJustification"), + OptionalString(cell, "date")); + } + + private static List Strings(JsonElement element, string name) + { + List values = []; + if (element.TryGetProperty(name, out JsonElement array)) + { + foreach (JsonElement value in array.EnumerateArray()) + { + values.Add(value.GetString()!); + } + } + + return values; + } + + private static string? OptionalString(JsonElement element, string name) + { + return element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + internal sealed record Row( + string Id, + string? Parent, + IReadOnlyList SubRows, + string Title, + string TitleFr, + string Owner, + IReadOnlyList RequiredLevels, + string Blocks, + string AuditRef, + IReadOnlyList Findings, + IReadOnlyList Preconditions, + string Operation, + string Expected, + string ExpectedCategory, + IReadOnlyDictionary Levels); + + internal sealed record Cell( + string Level, + string Status, + string? PassKind, + string EvidenceKind, + string? ProfileId, + IReadOnlyList Evidence, + string? Justification, + string? Expected, + string? TreeHash, + string? NupkgSha256, + string? TransferJustification, + string? Date) + { + internal bool IsHostLevel => Level is "C3" or "C4"; + + internal bool IsExecuted => Status is "Passed" or "Failed"; + } + + internal sealed record Evidence( + string Kind, + string? Project, + string? File, + string? Test, + string? Trait, + string? ReceiptId, + string? Path, + string? Sha256, + string? Url); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationRules.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationRules.cs new file mode 100644 index 00000000..657e18c3 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationRules.cs @@ -0,0 +1,399 @@ +using System.Globalization; +using System.Text.Json; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// +/// The semantic rules of the qualification matrix that a JSON schema cannot express: structure, profiles, evidence +/// kinds, sub-row aggregation, receipt resolution and freshness, and the link between Automated evidence and the +/// traited test methods. Every method returns one message per violation, so a test can assert an empty list and +/// print every offender at once. +/// +internal static class QualificationRules +{ + /// Row ids are unique and sorted, sub-rows and parents agree, and every required level has a cell. + internal static IReadOnlyList Structure(QualificationMatrix matrix) + { + List errors = []; + HashSet seen = new(StringComparer.Ordinal); + string? previous = null; + foreach (QualificationMatrix.Row row in matrix.Rows) + { + if (!seen.Add(row.Id)) + { + errors.Add($"{row.Id}: the id appears more than once."); + } + + if (previous is not null && string.CompareOrdinal(previous, row.Id) >= 0) + { + errors.Add($"{row.Id}: rows must be sorted by id (ordinal); it follows {previous}."); + } + + previous = row.Id; + CheckParent(matrix, row, errors); + CheckRequiredLevels(row, errors); + } + + return errors; + } + + /// + /// Cells cite profiles of the support profile; a C3/C4 cell cites a qualifiable profile, and no executed cell + /// cites the documentary public-source profile. + /// + internal static IReadOnlyList Profiles(QualificationMatrix matrix, JsonElement supportProfile) + { + Dictionary qualifiable = SupportProfileRules.QualifiableByProfileId(supportProfile); + List errors = []; + foreach (string profile in matrix.Profiles) + { + if (!qualifiable.ContainsKey(profile)) + { + errors.Add($"matrix.profiles lists '{profile}', which the support profile does not define."); + } + } + + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in matrix.Cells()) + { + if (cell.ProfileId is null) + { + continue; + } + + string at = At(row, cell); + if (!qualifiable.TryGetValue(cell.ProfileId, out bool isQualifiable) || + !Contains(matrix.Profiles, cell.ProfileId)) + { + errors.Add($"{at}: cites the unknown profile '{cell.ProfileId}'."); + continue; + } + + if (cell.IsExecuted && !isQualifiable) + { + errors.Add($"{at}: a {cell.Status} cell cites the documentary profile '{cell.ProfileId}', which is never qualifiable."); + } + else if (cell.IsHostLevel && !isQualifiable) + { + errors.Add($"{at}: a host-level cell must name a qualifiable profile, not '{cell.ProfileId}'."); + } + } + + return errors; + } + + /// + /// NotExecuted is ToQualify; an executed C0-C2 cell is ObservedSource; an executed C3/C4 cell is ObservedHost; a + /// NotApplicable cell is a ProposedDecision, or ObservedHost when a receipt records it. + /// + internal static IReadOnlyList EvidenceKinds(QualificationMatrix matrix) + { + List errors = []; + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in matrix.Cells()) + { + string expected = cell.Status switch + { + "NotExecuted" => "ToQualify", + "Passed" or "Failed" => cell.IsHostLevel ? "ObservedHost" : "ObservedSource", + _ => cell.Evidence.Count == 0 ? "ProposedDecision" : "ObservedHost" + }; + if (!string.Equals(cell.EvidenceKind, expected, StringComparison.Ordinal)) + { + errors.Add($"{At(row, cell)}: a {cell.Status} cell has evidenceKind {cell.EvidenceKind}, expected {expected}."); + } + } + + return errors; + } + + /// + /// A parent row's cell equals the aggregate of its sub-rows' cells at that level: Failed if any is Failed, + /// NotApplicable if all are, Passed if all are Passed or NotApplicable, otherwise NotExecuted. + /// + internal static IReadOnlyList Aggregation(QualificationMatrix matrix) + { + List errors = []; + foreach (QualificationMatrix.Row parent in matrix.Rows) + { + if (parent.SubRows.Count == 0) + { + continue; + } + + foreach ((string level, QualificationMatrix.Cell parentCell) in parent.Levels) + { + List statuses = []; + foreach (string subRowId in parent.SubRows) + { + if (matrix.Find(subRowId) is { } subRow && + subRow.Levels.TryGetValue(level, out QualificationMatrix.Cell? subCell)) + { + statuses.Add(subCell.Status); + } + } + + if (statuses.Count == 0) + { + continue; + } + + string aggregate = Aggregate(statuses); + if (!string.Equals(parentCell.Status, aggregate, StringComparison.Ordinal)) + { + errors.Add($"{parent.Id} {level}: is {parentCell.Status}, but its sub-rows ({string.Join(", ", statuses)}) aggregate to {aggregate}."); + } + } + } + + return errors; + } + + /// The contract's aggregation of sub-row statuses (shared-contracts section 2.2). + internal static string Aggregate(IReadOnlyCollection statuses) + { + bool allNotApplicable = true; + bool allPassedOrNotApplicable = true; + foreach (string status in statuses) + { + if (string.Equals(status, "Failed", StringComparison.Ordinal)) + { + return "Failed"; + } + + allNotApplicable &= string.Equals(status, "NotApplicable", StringComparison.Ordinal); + allPassedOrNotApplicable &= status is "Passed" or "NotApplicable"; + } + + if (allNotApplicable) + { + return "NotApplicable"; + } + + return allPassedOrNotApplicable ? "Passed" : "NotExecuted"; + } + + /// + /// Every receipt a cell cites is committed at its path with the cited LF-normalized SHA-256, qualifies that row, + /// level, status and profile, and was produced from the cell's tree and package unless the cell carries a + /// transferJustification. + /// + internal static IReadOnlyList Receipts(QualificationMatrix matrix, Func loadReceipt) + { + List errors = []; + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in matrix.Cells()) + { + foreach (QualificationMatrix.Evidence evidence in cell.Evidence) + { + if (string.Equals(evidence.Kind, "Receipt", StringComparison.Ordinal)) + { + CheckReceiptEvidence(row, cell, evidence, loadReceipt, errors); + } + } + } + + return errors; + } + + /// + /// Automated evidence names a *.Tests project of the solution, a file of that project, a method declared in it, + /// and the trait that method carries for this row. + /// + internal static IReadOnlyList AutomatedEvidence(QualificationMatrix matrix, + IReadOnlySet solutionProjects) + { + List errors = []; + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in matrix.Cells()) + { + foreach (QualificationMatrix.Evidence evidence in cell.Evidence) + { + if (string.Equals(evidence.Kind, "Automated", StringComparison.Ordinal)) + { + CheckAutomatedEvidence(row, cell, evidence, solutionProjects, errors); + } + } + } + + return errors; + } + + /// Every method-level Qualification trait is cited by the matrix, and every citation names a row. + internal static IReadOnlyList TraitParity(QualificationMatrix matrix, TestSourceIndex index) + { + List errors = [.. index.Violations]; + HashSet cited = new(StringComparer.Ordinal); + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in matrix.Cells()) + { + foreach (QualificationMatrix.Evidence evidence in cell.Evidence) + { + if (string.Equals(evidence.Kind, "Automated", StringComparison.Ordinal)) + { + cited.Add(evidence.File + "|" + evidence.Test + "|" + evidence.Trait); + } + } + } + + foreach (TestSourceIndex.TraitUse trait in index.Traits) + { + if (matrix.Find(trait.Value) is null) + { + errors.Add($"{trait.File}:{trait.Line}: {trait.Test} carries Qualification={trait.Value}, which is not a matrix row."); + } + else if (!cited.Contains(trait.File + "|" + trait.Test + "|Qualification=" + trait.Value)) + { + errors.Add($"{trait.File}:{trait.Line}: {trait.Test} carries Qualification={trait.Value}, but no matrix cell of {trait.Value} cites it as Automated evidence."); + } + } + + return errors; + } + + private static void CheckParent(QualificationMatrix matrix, QualificationMatrix.Row row, List errors) + { + int dot = row.Id.IndexOf('.', StringComparison.Ordinal); + if (row.Parent is null) + { + if (dot >= 0) + { + errors.Add($"{row.Id}: a sub-row must name its parent."); + } + } + else if (dot < 0 || !string.Equals(row.Id[..dot], row.Parent, StringComparison.Ordinal)) + { + errors.Add($"{row.Id}: parent '{row.Parent}' is not the id prefix."); + } + else if (matrix.Find(row.Parent) is not { } parent || !Contains(parent.SubRows, row.Id) || parent.Parent is not null) + { + errors.Add($"{row.Id}: parent '{row.Parent}' must be a top row that lists it in subRows."); + } + + foreach (string subRowId in row.SubRows) + { + if (matrix.Find(subRowId) is not { } subRow || !string.Equals(subRow.Parent, row.Id, StringComparison.Ordinal)) + { + errors.Add($"{row.Id}: subRows lists '{subRowId}', which is not a sub-row of it."); + } + } + } + + private static void CheckRequiredLevels(QualificationMatrix.Row row, List errors) + { + HashSet required = new(StringComparer.Ordinal); + foreach (string level in row.RequiredLevels) + { + if (!required.Add(level)) + { + errors.Add($"{row.Id}: requiredLevels repeats {level}."); + } + + if (!row.Levels.ContainsKey(level)) + { + errors.Add($"{row.Id}: required level {level} has no cell."); + } + } + } + + private static void CheckReceiptEvidence(QualificationMatrix.Row row, QualificationMatrix.Cell cell, + QualificationMatrix.Evidence evidence, Func loadReceipt, List errors) + { + string at = At(row, cell) + " receipt " + evidence.ReceiptId; + string expectedPath = QualificationDocuments.ReceiptDirectory + "/" + row.Id + "/" + evidence.ReceiptId + ".json"; + if (!string.Equals(evidence.Path, expectedPath, StringComparison.Ordinal)) + { + errors.Add($"{at}: path must be {expectedPath}."); + return; + } + + JsonElement? loaded = loadReceipt(expectedPath); + if (loaded is not { } receipt) + { + errors.Add($"{at}: {expectedPath} is not committed."); + return; + } + + if (!string.Equals(QualificationDocuments.CommittedJsonSha256(expectedPath), evidence.Sha256, + StringComparison.Ordinal)) + { + errors.Add($"{at}: the cited sha256 is not the LF-normalized SHA-256 of {expectedPath}."); + } + + ExpectEqual(errors, at, "receiptId", receipt.GetProperty("receiptId").GetString(), evidence.ReceiptId); + ExpectEqual(errors, at, "qualificationId", receipt.GetProperty("qualificationId").GetString(), row.Id); + ExpectEqual(errors, at, "level", receipt.GetProperty("level").GetString(), cell.Level); + ExpectEqual(errors, at, "status", receipt.GetProperty("status").GetString(), cell.Status); + ExpectEqual(errors, at, "profileId", receipt.GetProperty("profileId").GetString(), cell.ProfileId); + CheckFreshness(cell, receipt, at, errors); + } + + private static void CheckFreshness(QualificationMatrix.Cell cell, JsonElement receipt, string at, List errors) + { + string? tree = receipt.GetProperty("repository").GetProperty("treeHash").GetString(); + string? package = receipt.GetProperty("package").GetProperty("nupkgSha256").GetString(); + bool sameTree = string.Equals(tree, cell.TreeHash, StringComparison.Ordinal); + bool samePackage = string.Equals(package, cell.NupkgSha256, StringComparison.Ordinal); + if (cell.IsExecuted && (!sameTree || !samePackage) && cell.TransferJustification is null) + { + errors.Add($"{at}: produced from tree {tree} and package {package}, not the cell's {cell.TreeHash} / {cell.NupkgSha256}; add a transferJustification or a fresh receipt."); + } + } + + private static void CheckAutomatedEvidence(QualificationMatrix.Row row, QualificationMatrix.Cell cell, + QualificationMatrix.Evidence evidence, IReadOnlySet solutionProjects, List errors) + { + string at = At(row, cell) + " " + evidence.Test; + string project = evidence.Project ?? string.Empty; + if (!solutionProjects.Contains(project) || !project.EndsWith(".Tests.csproj", StringComparison.Ordinal)) + { + errors.Add($"{at}: '{project}' is not a *.Tests module of CheatEngine.SDK.slnx, so CI does not run it."); + } + + string projectDirectory = project[..(project.LastIndexOf('/') + 1)]; + string file = evidence.File ?? string.Empty; + if (!file.StartsWith(projectDirectory, StringComparison.Ordinal) || !QualificationDocuments.Exists(file)) + { + errors.Add($"{at}: '{file}' is not a file of {project}."); + return; + } + + string expectedTrait = "Qualification=" + row.Id; + if (!string.Equals(evidence.Trait, expectedTrait, StringComparison.Ordinal)) + { + errors.Add($"{at}: trait must be {expectedTrait}, found {evidence.Trait}."); + } + + string[] parts = (evidence.Test ?? string.Empty).Split('.'); + IReadOnlyList? traits = TestSourceIndex.TraitsOfMethod(file, parts[0], parts[^1]); + if (traits is null) + { + errors.Add($"{at}: no method {parts[^1]} of {parts[0]} is declared in {file}."); + } + else if (!Contains(traits, row.Id)) + { + errors.Add($"{at}: the method has no [Trait(\"Qualification\", \"{row.Id}\")] in its attribute block."); + } + } + + private static void ExpectEqual(List errors, string at, string field, string? actual, string? expected) + { + if (!string.Equals(actual, expected, StringComparison.Ordinal)) + { + errors.Add($"{at}: receipt {field} is '{actual}', expected '{expected}'."); + } + } + + private static bool Contains(IEnumerable values, string value) + { + foreach (string candidate in values) + { + if (string.Equals(candidate, value, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static string At(QualificationMatrix.Row row, QualificationMatrix.Cell cell) + { + return string.Create(CultureInfo.InvariantCulture, $"{row.Id} {cell.Level}"); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/ReceiptRules.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/ReceiptRules.cs new file mode 100644 index 00000000..db90024b --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/ReceiptRules.cs @@ -0,0 +1,178 @@ +using System.Globalization; +using System.Text.Json; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// +/// The rules of a qualification receipt beyond its schema: it cites a qualifiable profile, encodes its identity, +/// matches the profile's host facts unless it records a NotApplicable preflight mismatch, and carries no local path. +/// Committed receipts additionally sit at their canonical path with an event log whose LF-normalized hash matches. +/// +internal static class ReceiptRules +{ + /// Validates one receipt document against the support profile. + internal static IReadOnlyList Validate(JsonElement receipt, JsonElement supportProfile) + { + List errors = + [.. QualificationDocuments.Schema(QualificationContract.ReceiptSchemaFile).Validate(receipt)]; + if (errors.Count > 0) + { + return errors; + } + + foreach (string path in TextRules.AbsoluteLocalPaths(receipt)) + { + errors.Add("contains an absolute local path at " + path); + } + + string profileId = receipt.GetProperty("profileId").GetString()!; + JsonElement? profile = SupportProfileRules.Find(supportProfile, profileId); + if (profile is null) + { + errors.Add($"profileId '{profileId}' is not a profile of the support profile."); + } + else if (!SupportProfileRules.QualifiableByProfileId(supportProfile)[profileId]) + { + errors.Add($"profileId '{profileId}' is a documentary profile, which is never qualifiable."); + } + else if (!string.Equals(receipt.GetProperty("status").GetString(), "NotApplicable", StringComparison.Ordinal)) + { + CheckPreflight(receipt.GetProperty("host"), profile.Value, errors); + } + + CheckIdentity(receipt, errors); + return errors; + } + + /// Validates a committed receipt file, its location and its event log. + internal static IReadOnlyList ValidateCommitted(string receiptPath, JsonElement supportProfile) + { + JsonElement receipt = QualificationDocuments.LoadJson(receiptPath); + List errors = []; + foreach (string error in Validate(receipt, supportProfile)) + { + errors.Add(receiptPath + ": " + error); + } + + if (errors.Count > 0) + { + return errors; + } + + string receiptId = receipt.GetProperty("receiptId").GetString()!; + string expectedPath = QualificationDocuments.ReceiptDirectory + "/" + + receipt.GetProperty("qualificationId").GetString() + "/" + receiptId + ".json"; + if (!string.Equals(receiptPath, expectedPath, StringComparison.Ordinal)) + { + errors.Add($"{receiptPath}: must be committed as {expectedPath}."); + } + + JsonElement eventLog = receipt.GetProperty("eventLog"); + string eventsPath = receiptPath[..(receiptPath.LastIndexOf('/') + 1)] + eventLog.GetProperty("path").GetString(); + if (!QualificationDocuments.Exists(eventsPath)) + { + errors.Add($"{receiptPath}: its event log {eventsPath} is not committed."); + return errors; + } + + if (!string.Equals(QualificationDocuments.CommittedJsonSha256(eventsPath), + eventLog.GetProperty("sha256").GetString(), StringComparison.Ordinal)) + { + errors.Add($"{receiptPath}: eventLog.sha256 is not the LF-normalized SHA-256 of {eventsPath}."); + } + + JsonElement events = QualificationDocuments.LoadJson(eventsPath); + foreach (string error in ValidateEventLog(events, receiptId)) + { + errors.Add(eventsPath + ": " + error); + } + + return errors; + } + + /// Validates an event log: schema, receipt id, no local path and no raw debug output. + internal static IReadOnlyList ValidateEventLog(JsonElement events, string receiptId) + { + List errors = + [.. QualificationDocuments.Schema(QualificationContract.EventsSchemaFile).Validate(events)]; + if (errors.Count > 0) + { + return errors; + } + + if (!string.Equals(events.GetProperty("receiptId").GetString(), receiptId, StringComparison.Ordinal)) + { + errors.Add($"receiptId must be {receiptId}."); + } + + foreach (string path in TextRules.AbsoluteLocalPaths(events)) + { + errors.Add("contains an unredacted local path at " + path); + } + + int index = 0; + foreach (JsonElement item in events.GetProperty("events").EnumerateArray()) + { + if (TextRules.ContainsRawDebugOutput(item.GetProperty("message").GetString() ?? string.Empty)) + { + errors.Add(string.Create(CultureInfo.InvariantCulture, + $"/events/{index}/message contains raw debug output; record structured values instead.")); + } + + index++; + } + + return errors; + } + + /// + /// R-yyyyMMddTHHmmssZ-Qid-xxxxxxxx: the start of the run (timings.startedUtc, seconds), the + /// qualification id and the first eight hex digits of the package SHA-256. + /// + internal static string ExpectedReceiptId(JsonElement receipt) + { + DateTimeOffset started = DateTimeOffset.Parse( + receipt.GetProperty("timings").GetProperty("startedUtc").GetString()!, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + string package = receipt.GetProperty("package").GetProperty("nupkgSha256").GetString()!; + return "R-" + started.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture) + "-" + + receipt.GetProperty("qualificationId").GetString() + "-" + package[..8]; + } + + private static void CheckIdentity(JsonElement receipt, List errors) + { + string receiptId = receipt.GetProperty("receiptId").GetString()!; + string expected = ExpectedReceiptId(receipt); + if (!string.Equals(receiptId, expected, StringComparison.Ordinal)) + { + errors.Add($"receiptId '{receiptId}' must encode its start time, qualification id and package: '{expected}'."); + } + + string expectedLog = receiptId + QualificationDocuments.EventsSuffix; + if (!string.Equals(receipt.GetProperty("eventLog").GetProperty("path").GetString(), expectedLog, + StringComparison.Ordinal)) + { + errors.Add($"eventLog.path must be {expectedLog}."); + } + } + + private static void CheckPreflight(JsonElement host, JsonElement profile, List errors) + { + JsonElement profileHost = profile.GetProperty("host"); + Expect(errors, "host.ceExeName", host, "ceExeName", profileHost.GetProperty("exeName")); + Expect(errors, "host.ceExeSha256", host, "ceExeSha256", profileHost.GetProperty("exeSha256")); + Expect(errors, "host.ceFileVersion", host, "ceFileVersion", profileHost.GetProperty("version")); + Expect(errors, "host.luaDllSha256", host, "luaDllSha256", profile.GetProperty("lua").GetProperty("sha256")); + Expect(errors, "host.runtimeconfigSha256", host, "runtimeconfigSha256", + profile.GetProperty("runtime").GetProperty("runtimeconfig").GetProperty("sha256")); + } + + private static void Expect(List errors, string name, JsonElement host, string property, JsonElement expected) + { + string? actual = host.GetProperty(property).GetString(); + if (!string.Equals(actual, expected.GetString(), StringComparison.Ordinal)) + { + errors.Add($"{name} '{actual}' differs from the profile ('{expected.GetString()}'): a run on another host is NotApplicable with a justification, never Passed or Failed."); + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/SupportProfileRules.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/SupportProfileRules.cs new file mode 100644 index 00000000..5d6cb80e --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/SupportProfileRules.cs @@ -0,0 +1,81 @@ +using System.Text.Json; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// Reads and checks support-profile.json beyond its schema. +internal static class SupportProfileRules +{ + /// Schema validity plus the semantic rules: unique ids, no local path, the two frozen profile ids. + internal static IReadOnlyList Validate(JsonElement supportProfile) + { + List errors = + [.. QualificationDocuments.Schema(QualificationContract.SupportProfileSchemaFile).Validate(supportProfile)]; + if (errors.Count > 0) + { + return errors; + } + + CheckUnique(supportProfile, "profiles", "id", errors); + CheckUnique(supportProfile, "decisions", "id", errors); + CheckUnique(supportProfile, "unsupportedRoutes", "id", errors); + foreach (string path in TextRules.AbsoluteLocalPaths(supportProfile)) + { + errors.Add("contains an absolute local path at " + path); + } + + Dictionary qualifiable = QualifiableByProfileId(supportProfile); + if (!qualifiable.TryGetValue(QualificationContract.DocumentaryProfileId, out bool documentary) || documentary) + { + errors.Add($"'{QualificationContract.DocumentaryProfileId}' must exist and stay Documentary."); + } + + if (!qualifiable.TryGetValue(QualificationContract.QualifiableProfileId, out bool isQualifiable) || + !isQualifiable) + { + errors.Add($"'{QualificationContract.QualifiableProfileId}' must exist and stay Qualifiable."); + } + + return errors; + } + + /// Maps each profile id to whether the profile is qualifiable. + internal static Dictionary QualifiableByProfileId(JsonElement supportProfile) + { + Dictionary profiles = new(StringComparer.Ordinal); + foreach (JsonElement profile in supportProfile.GetProperty("profiles").EnumerateArray()) + { + profiles[profile.GetProperty("id").GetString()!] = profile.GetProperty("qualifiable").GetBoolean() && + string.Equals(profile.GetProperty("kind").GetString(), + "Qualifiable", StringComparison.Ordinal); + } + + return profiles; + } + + /// Returns the profile with the given id. + internal static JsonElement? Find(JsonElement supportProfile, string id) + { + foreach (JsonElement profile in supportProfile.GetProperty("profiles").EnumerateArray()) + { + if (string.Equals(profile.GetProperty("id").GetString(), id, StringComparison.Ordinal)) + { + return profile; + } + } + + return null; + } + + private static void CheckUnique(JsonElement document, string arrayName, string idName, List errors) + { + HashSet seen = new(StringComparer.Ordinal); + foreach (JsonElement item in document.GetProperty(arrayName).EnumerateArray()) + { + string id = item.GetProperty(idName).GetString()!; + if (!seen.Add(id)) + { + errors.Add($"{arrayName}: the id '{id}' appears more than once."); + } + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TestSourceIndex.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TestSourceIndex.cs new file mode 100644 index 00000000..005fdc2a --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TestSourceIndex.cs @@ -0,0 +1,205 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// +/// A text index of the Qualification traits in the test sources. A trait counts as evidence only on a test +/// method: it sits in the attribute block directly above the method signature. A class-level trait is reported as a +/// violation, because it would qualify every method of the class at once. +/// +internal sealed class TestSourceIndex +{ + internal const string TraitName = "Qualification"; + + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + + // Split so that this file never contains a literal trait the index would pick up. + private static readonly Regex TraitPattern = new( + "Trait\\s*\\(\\s*\"" + TraitName + "\"\\s*,\\s*\"(?[^\"]*)\"\\s*\\)", + RegexOptions.CultureInvariant, RegexTimeout); + + private static readonly Regex TypeDeclaration = new( + "\\b(?:class|record|struct|interface)\\s+(?[A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.CultureInvariant, RegexTimeout); + + private static readonly Regex MethodDeclaration = new( + "^\\s*(?:(?:public|internal|private|protected|static|async|unsafe|override|virtual|sealed|new)\\s+)+[A-Za-z_][A-Za-z0-9_<>,.?\\[\\] ]*\\s+(?[A-Za-z_][A-Za-z0-9_]*)\\s*\\(", + RegexOptions.CultureInvariant, RegexTimeout); + + private TestSourceIndex(IReadOnlyList traits, IReadOnlyList violations) + { + Traits = traits; + Violations = violations; + } + + /// Every method-level Qualification trait found. + internal IReadOnlyList Traits + { + get; + } + + /// Traits that are not on a method (class-level, or not followed by a declaration). + internal IReadOnlyList Violations + { + get; + } + + /// Scans tests/**/*.cs below the repository root, skipping build output. + internal static TestSourceIndex Scan() + { + List traits = []; + List violations = []; + foreach (string file in RepositoryRoot.EnumerateSourceFiles("*.cs")) + { + if (!file.StartsWith("tests/", StringComparison.Ordinal)) + { + continue; + } + + string[] lines = File.ReadAllLines(QualificationDocuments.Absolute(file)); + ScanFile(file, lines, traits, violations); + } + + return new TestSourceIndex(traits, violations); + } + + /// + /// Returns the Qualification traits in the attribute block of declared in type + /// of , or when the method is absent. + /// + internal static IReadOnlyList? TraitsOfMethod(string file, string className, string methodName) + { + string path = QualificationDocuments.Absolute(file); + if (!File.Exists(path)) + { + return null; + } + + string[] lines = File.ReadAllLines(path); + string? currentType = null; + for (int index = 0; index < lines.Length; index++) + { + currentType = TopLevelTypeName(lines[index]) ?? currentType; + Match method = MethodDeclaration.Match(lines[index]); + if (method.Success && string.Equals(method.Groups["name"].Value, methodName, StringComparison.Ordinal) && + string.Equals(currentType, className, StringComparison.Ordinal)) + { + return TraitValues(AttributeBlockAbove(lines, index)); + } + } + + return null; + } + + private static void ScanFile(string file, string[] lines, List traits, List violations) + { + string? currentType = null; + for (int index = 0; index < lines.Length; index++) + { + currentType = TopLevelTypeName(lines[index]) ?? currentType; + foreach (Match trait in TraitPattern.Matches(lines[index])) + { + string value = trait.Groups["value"].Value; + string location = file + ":" + (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture); + int declaration = NextDeclaration(lines, index); + if (declaration < 0) + { + violations.Add(location + " Qualification trait '" + value + "' is not followed by a declaration."); + continue; + } + + Match method = MethodDeclaration.Match(lines[declaration]); + if (!method.Success || TypeDeclaration.IsMatch(lines[declaration])) + { + violations.Add(location + " Qualification trait '" + value + + "' is not on a test method (method-level traits only)."); + continue; + } + + traits.Add(new TraitUse(file, currentType ?? string.Empty, method.Groups["name"].Value, value, + index + 1)); + } + } + } + + // Test sources use file-scoped namespaces, so a top-level type declaration starts in column 0; nested types are + // indented and do not change the class that owns the following test methods. + private static string? TopLevelTypeName(string line) + { + if (line.Length == 0 || char.IsWhiteSpace(line[0]) || line.StartsWith("//", StringComparison.Ordinal) || + line.StartsWith('[')) + { + return null; + } + + Match type = TypeDeclaration.Match(line); + return type.Success ? type.Groups["name"].Value : null; + } + + private static int NextDeclaration(string[] lines, int attributeLine) + { + for (int index = attributeLine + 1; index < lines.Length; index++) + { + string trimmed = lines[index].Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith("//", StringComparison.Ordinal)) + { + return -1; + } + + bool isDeclaration = !trimmed.StartsWith('[') && + (MethodDeclaration.IsMatch(lines[index]) || TypeDeclaration.IsMatch(lines[index])); + if (isDeclaration || !IsAttributeOrContinuation(trimmed)) + { + return index; + } + } + + return -1; + } + + // The attribute block is the run of lines directly above the declaration that are attributes or their wrapped + // arguments. It stops at a blank line, a comment or the end of the previous member. + private static string AttributeBlockAbove(string[] lines, int declaration) + { + int start = declaration; + for (int index = declaration - 1; index >= 0; index--) + { + string trimmed = lines[index].Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith("//", StringComparison.Ordinal) || + (!trimmed.StartsWith('[') && (trimmed.EndsWith('}') || trimmed.EndsWith(';') || trimmed.EndsWith('{')))) + { + break; + } + + start = index; + } + + return string.Join('\n', lines[start..declaration]); + } + + private static bool IsAttributeOrContinuation(string trimmed) + { + return trimmed.StartsWith('[') || trimmed.StartsWith('"') || trimmed.StartsWith("Justification", + StringComparison.Ordinal) || trimmed.EndsWith(']') || trimmed.EndsWith(',') || trimmed.EndsWith('='); + } + + private static List TraitValues(string attributeBlock) + { + List values = []; + foreach (Match trait in TraitPattern.Matches(attributeBlock)) + { + values.Add(trait.Groups["value"].Value); + } + + return values; + } + + /// One method-level Qualification trait. + internal sealed record TraitUse(string File, string ClassName, string MethodName, string Value, int Line) + { + /// The Class.Method form used by Automated evidence. + internal string Test => ClassName + "." + MethodName; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TextRules.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TextRules.cs new file mode 100644 index 00000000..5d0b5a76 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TextRules.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// Text rules shared by every qualification document: no local path, no raw debug output, no global score. +internal static class TextRules +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + + /// + /// A drive-rooted path (C:\, d:/, not the s:/ of https://), a Windows or macOS user + /// profile segment, a home directory or a file:// URI. Environment placeholders such as + /// %ProgramFiles% are allowed. + /// + private static readonly Regex AbsoluteLocalPath = new( + @"(?A DebugView capture line (sequence, time, [pid]) or the SDK host log prefix. + private static readonly Regex RawDebugOutput = new( + @"(?:^|\n)\s*\d+\s+\d+\.\d+\s+\[\d+\]|\[CheatEngine\.SDK\.Hosting\]\s+(?:Information|Warning|Error)", + RegexOptions.CultureInvariant, RegexTimeout); + + /// A number followed by a percent sign: the qualification documents never publish a global score. + private static readonly Regex Percentage = new(@"\d\s?%", RegexOptions.CultureInvariant, RegexTimeout); + + internal static bool ContainsAbsoluteLocalPath(string text) + { + return AbsoluteLocalPath.IsMatch(text); + } + + internal static bool ContainsRawDebugOutput(string text) + { + return RawDebugOutput.IsMatch(text); + } + + internal static bool ContainsPercentage(string text) + { + return Percentage.IsMatch(text); + } + + /// Every string value (and property name) of that holds a local path. + internal static IReadOnlyList AbsoluteLocalPaths(JsonElement document) + { + List found = []; + Collect(document, string.Empty, found); + return found; + } + + private static void Collect(JsonElement element, string pointer, List found) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + string child = pointer + "/" + property.Name; + if (ContainsAbsoluteLocalPath(property.Name)) + { + found.Add(child + " (property name)"); + } + + Collect(property.Value, child, found); + } + + break; + case JsonValueKind.Array: + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + Collect(item, pointer + "/" + index.ToString(CultureInfo.InvariantCulture), found); + index++; + } + + break; + case JsonValueKind.String: + string value = element.GetString() ?? string.Empty; + if (ContainsAbsoluteLocalPath(value)) + { + found.Add(pointer + ": " + value); + } + + break; + } + } +} From c36890e9d22e3e03a01989915486ade9615f8287 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:42:11 +0200 Subject: [PATCH 023/199] Publish the Cheat Engine support profiles A qualification result must name the exact host it ran on, and the public Cheat Engine source must never stand in for the 7.7 binary (F01, ADR-02). Add docs/qualification/support-profile.json and its page with two profiles: - ce-public-src-ec45d5f, documentary and never qualifiable: the public source that declares 7.5.1 and the historical CLR bootstrap. - ce-7.7.0.10621-x64-managed-hostfxr, the only qualifiable profile: exact executable hash and version, excluded variants, Lua module, celua.txt, the locally modified ce.runtimeconfig.json, observed runtimes, the profile-relevant HKCU names (no data), LocalProcess as the only qualified backend, the authorized targets and plugin contract version 6. The page also separates the identities (public source, binary, SDK tree, v1.0.0 tag, consumed 1.0.0 package), the SDK-branch and Client 1.0.0 tuples with their three package identities, the runtime policy warning, the proposed Checkpoint A decisions CPA-1 to CPA-3, the unsupported routes and the dated measurement record. Hashes were measured read-only on 2026-09-23; no test reads the installation. SupportProfileTests pin the documents to the schema, to the committed Lua fixture, the LiveProbe authorization constants, AbiConstants.SdkVersion and the checked-in bridge, reject a qualifiable documentary profile, and enforce canonical JSON, no local path and no global score. --- CheatEngine.SDK.slnx | 4 + docs/qualification/support-profile.json | 387 ++++++++++++++++++ docs/qualification/support-profile.md | 152 ++++++- .../Qualification/SupportProfileTests.cs | 269 ++++++++++++ 4 files changed, 810 insertions(+), 2 deletions(-) create mode 100644 docs/qualification/support-profile.json create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 43b35a82..1e90e3ed 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -51,6 +51,10 @@ + + + + diff --git a/docs/qualification/support-profile.json b/docs/qualification/support-profile.json new file mode 100644 index 00000000..5fbd7a3d --- /dev/null +++ b/docs/qualification/support-profile.json @@ -0,0 +1,387 @@ +{ + "schema": "cheatengine-support-profile/v0", + "profiles": [ + { + "id": "ce-public-src-ec45d5f", + "kind": "Documentary", + "qualifiable": false, + "qualificationStatus": "NotExecuted", + "description": "Comparative source only: the public cheat-engine/cheat-engine tree at ec45d5f declares version 7.5.1 and the historical string/CLR managed bootstrap (audit analyses/01, lines 20-22). It documents behaviour of that source; it is never proof of the CE 7.7.0.10621 binary and can never back a Passed or Failed qualification result.", + "source": { + "repository": "cheat-engine/cheat-engine", + "commit": "ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37" + }, + "celua": { + "sha256": "aa1342b4a5d5d5c65b255fb3a8fd7b6bcbbac1cd138961669d9f37f43e0b9c00" + }, + "evidenceKind": "ObservedSource" + }, + { + "id": "ce-7.7.0.10621-x64-managed-hostfxr", + "kind": "Qualifiable", + "qualifiable": true, + "qualificationStatus": "NotExecuted", + "description": "The only qualifiable profile: the Cheat Engine 7.7.0.10621 x64 executable identified by its SHA-256, loading managed plugins through nethost/hostfxr with the locally modified ce.runtimeconfig.json recorded below. The SDK plugin contract version (GetVersion) is 6, independent of the Cheat Engine product version. No scenario has run on it yet: every C3/C4 cell of the matrix is NotExecuted until a committed receipt says otherwise.", + "host": { + "product": "Cheat Engine", + "version": "7.7.0.10621", + "exeName": "cheatengine-x86_64.exe", + "exeSha256": "9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d", + "machine": "AMD64", + "excludedVariants": [ + { + "exeName": "cheatengine-x86_64-SSE4-AVX2.exe", + "sha256": "9d861d651ab9d1dc3c09ae34c8ed5dee3d1a29b080784c3c48773494c9350230", + "reason": "Same FileVersion 7.7.0.10621 but a different binary; not profiled, never used by the runner." + }, + { + "exeName": "Cheat Engine.exe", + "sha256": "5313618d93640bb29b66baadf2339de85e593a51715290dadece6d58e039a75e", + "reason": "Launcher (FileVersion 6.3.0.0) that selects an executable at run time; never used, the runner starts the profiled executable directly." + }, + { + "exeName": "cheatengine-i386.exe", + "sha256": "0a4b63eadbe824bcc5095a97ccdd8c580573ed72361514025abe1bf25f1387c9", + "reason": "x86 host: unsupported, the SDK and its native bridge are x64-only." + } + ] + }, + "lua": { + "module": "lua53-64.dll", + "sha256": "c95dcdfa0f60f97b43d970d77fd1bb907af4de04b500a3c89a99600b20b35bd2" + }, + "celua": { + "sha256": "aa1342b4a5d5d5c65b255fb3a8fd7b6bcbbac1cd138961669d9f37f43e0b9c00" + }, + "runtime": { + "loadProfile": "managed-hostfxr", + "runtimeconfig": { + "sha256": "68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b", + "classification": "LocalModified", + "observedDate": "2026-09-19", + "tfm": "net10.0", + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "10.0.0", + "rollForward": "LatestMinor" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "10.0.0", + "rollForward": "LatestMinor" + }, + { + "name": "Microsoft.AspNetCore.App", + "version": "10.0.0", + "rollForward": "LatestMinor" + } + ] + }, + "dotnetRuntimesObserved": [ + { + "name": "Microsoft.AspNetCore.App", + "version": "10.0.8", + "architecture": "x64" + }, + { + "name": "Microsoft.AspNetCore.App", + "version": "10.0.11", + "architecture": "x64" + }, + { + "name": "Microsoft.AspNetCore.App", + "version": "10.0.12", + "architecture": "x64" + }, + { + "name": "Microsoft.NETCore.App", + "version": "8.0.31", + "architecture": "x64" + }, + { + "name": "Microsoft.NETCore.App", + "version": "10.0.8", + "architecture": "x64" + }, + { + "name": "Microsoft.NETCore.App", + "version": "10.0.11", + "architecture": "x64" + }, + { + "name": "Microsoft.NETCore.App", + "version": "10.0.12", + "architecture": "x64" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "8.0.31", + "architecture": "x64" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "10.0.8", + "architecture": "x64" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "10.0.11", + "architecture": "x64" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "10.0.12", + "architecture": "x64" + }, + { + "name": "Microsoft.NETCore.App", + "version": "6.0.36", + "architecture": "x86" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "6.0.36", + "architecture": "x86" + } + ] + }, + "registry": { + "key": "HKCU\\Software\\Cheat Engine", + "sharedAcrossCopies": true, + "profileRelevantValues": [ + { + "kind": "Subkey", + "name": "Plugins64", + "reason": "Plugins registered through Settings > Plugins are persisted here; every copy of Cheat Engine, including a sandbox copy, reads the same list." + }, + { + "kind": "Subkey", + "name": "dotnetinfo", + "reason": "Written by Cheat Engine for its .NET host support; part of the managed-hostfxr route state." + }, + { + "kind": "Subkey", + "name": "VersionCheck", + "reason": "Controls the update check, the only network access observed at startup." + }, + { + "kind": "Value", + "name": "First Time User", + "reason": "Changes the first-start behaviour of a fresh copy." + }, + { + "kind": "Value", + "name": "RunAsAdmin", + "reason": "Elevation request; qualification runs are never elevated." + }, + { + "kind": "Value", + "name": "LuaEngine.showOnPrint", + "reason": "Opens the Lua Engine window when a script prints, which would disturb an unattended driver." + }, + { + "kind": "Value", + "name": "LuaScriptAction", + "reason": "How Cheat Engine treats table Lua scripts; the runner loads no table." + }, + { + "kind": "Value", + "name": "AutoAttach", + "reason": "Automatic process attachment; a qualification run selects its target explicitly." + }, + { + "kind": "Value", + "name": "Always AutoAttach", + "reason": "Automatic process attachment; a qualification run selects its target explicitly." + }, + { + "kind": "Value", + "name": "Use Windows Debugger", + "reason": "Debugger interface selection; no scenario starts a debugger." + }, + { + "kind": "Value", + "name": "Use VEH Debugger", + "reason": "Debugger interface selection; no scenario starts a debugger." + }, + { + "kind": "Value", + "name": "Use Kernel Debugger", + "reason": "Debugger interface selection; no scenario starts a debugger." + }, + { + "kind": "Value", + "name": "Use DBVM Debugger", + "reason": "Debugger interface selection; no scenario starts a debugger." + }, + { + "kind": "Value", + "name": "Use dbk32 OpenProcess", + "reason": "Kernel-driver process access; the qualified backend is a local process opened without a driver." + }, + { + "kind": "Value", + "name": "Use dbk32 ReadWriteProcessMemory", + "reason": "Kernel-driver memory access; not part of the qualified backend." + }, + { + "kind": "Value", + "name": "Use dbk32 QueryMemoryRegionEx", + "reason": "Kernel-driver region queries; not part of the qualified backend." + }, + { + "kind": "Value", + "name": "Use Processwatcher", + "reason": "Process-watcher driver; the process-watcher scenario (Q38) is NotApplicable on this profile." + } + ] + }, + "qualifiedBackends": [ + "LocalProcess" + ], + "authorizedTargets": [ + { + "kind": "QualificationTarget", + "arch": "x64", + "sha256": null, + "source": "tests/CheatEngine.SDK.QualificationTarget, published per run from the tested tree; its SHA-256 is recorded in each receipt." + }, + { + "kind": "QualificationTarget", + "arch": "x86", + "sha256": null, + "source": "tests/CheatEngine.SDK.QualificationTarget, published per run for win-x86; its SHA-256 is recorded in each receipt." + }, + { + "kind": "GtutorialI386", + "arch": "x86", + "sha256": "9131b1ca916d6ac1fb67224a67cf0f578105d43aeeebb03137e94d73cbe11bca", + "source": "gtutorial-i386.exe from the sandbox copy of the profiled installation; never committed." + } + ], + "sdkPluginContractVersion": 6, + "evidenceKind": "ExactInstalledFile" + } + ], + "decisions": [ + { + "id": "CPA-1", + "date": "2026-09-23", + "text": "The supported profile of 2.0 is ce-7.7.0.10621-x64-managed-hostfxr: Cheat Engine 7.7.0.10621 x64 with the locally modified ce.runtimeconfig.json (net10.0; Microsoft.NETCore.App, Microsoft.WindowsDesktop.App and Microsoft.AspNetCore.App 10.0.0, rollForward LatestMinor), disclosed as a local modification and not as an installer baseline. An installed Cheat Engine is never edited: the runtime configuration applies to every managed plugin of that installation.", + "evidenceKind": "ProposedDecision" + }, + { + "id": "CPA-2", + "date": "2026-09-23", + "text": "2.0 ships the managed hostfxr route only. The classic native plugin route (CEPlugin_* exports and the classic exports table) stays documentary, and the NativeAOT plugin route is unsupported (F02, Q41, Q42): a publish that succeeds is not a Cheat Engine load or unload.", + "evidenceKind": "ProposedDecision" + }, + { + "id": "CPA-3", + "date": "2026-09-23", + "text": "Conditional and optional API families (debugger, DBVM, speedhack, CEServer and the other deferred families of the audit, analyses/18) stay in the catalogue with explicit statuses and are outside the qualified scope of 2.0 (ADR-04, ADR-11).", + "evidenceKind": "ProposedDecision" + } + ], + "unsupportedRoutes": [ + { + "id": "historical-clr-loader", + "reason": "The string-argument CLR bootstrap of the public source (ec45d5f) is a different load profile (ADR-02); the SDK never falls back to it." + }, + { + "id": "nativeaot-plugin", + "reason": "A NativeAOT plugin cannot be unloaded with FreeLibrary (https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries), which the classic loader calls; unsupported until a residence model is qualified (F02, Q41, Q42)." + }, + { + "id": "x86-host", + "reason": "cheatengine-i386.exe is an x86 process; the SDK and its native bridge are x64-only (Q32.c)." + }, + { + "id": "sse4-avx2-host-variant", + "reason": "cheatengine-x86_64-SSE4-AVX2.exe has the same version but a different binary; it is not profiled, so a result obtained with it qualifies nothing." + } + ], + "measurements": [ + { + "subject": "cheatengine-x86_64.exe (FileVersion 7.7.0.10621, machine AMD64)", + "sha256": "9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d", + "date": "2026-09-23", + "method": "Get-FileHash -Algorithm SHA256 on the installed file under %ProgramFiles%\\Cheat Engine, read-only; the installation was never modified", + "evidenceKind": "ExactInstalledFile", + "declaredIn": [ + "tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs:16", + "tests/CheatEngine.SDK.LivePlugin/README.md:60" + ] + }, + { + "subject": "cheatengine-x86_64-SSE4-AVX2.exe (excluded variant)", + "sha256": "9d861d651ab9d1dc3c09ae34c8ed5dee3d1a29b080784c3c48773494c9350230", + "date": "2026-09-23", + "method": "Get-FileHash -Algorithm SHA256 on the installed file under %ProgramFiles%\\Cheat Engine, read-only; the installation was never modified", + "evidenceKind": "ExactInstalledFile", + "declaredIn": [] + }, + { + "subject": "Cheat Engine.exe (launcher, excluded)", + "sha256": "5313618d93640bb29b66baadf2339de85e593a51715290dadece6d58e039a75e", + "date": "2026-09-23", + "method": "Get-FileHash -Algorithm SHA256 on the installed file under %ProgramFiles%\\Cheat Engine, read-only; the installation was never modified", + "evidenceKind": "ExactInstalledFile", + "declaredIn": [] + }, + { + "subject": "cheatengine-i386.exe (x86 host, unsupported)", + "sha256": "0a4b63eadbe824bcc5095a97ccdd8c580573ed72361514025abe1bf25f1387c9", + "date": "2026-09-23", + "method": "Get-FileHash -Algorithm SHA256 on the installed file under %ProgramFiles%\\Cheat Engine, read-only; the installation was never modified", + "evidenceKind": "ExactInstalledFile", + "declaredIn": [] + }, + { + "subject": "lua53-64.dll of the installation", + "sha256": "c95dcdfa0f60f97b43d970d77fd1bb907af4de04b500a3c89a99600b20b35bd2", + "date": "2026-09-23", + "method": "Get-FileHash -Algorithm SHA256 on the installed file under %ProgramFiles%\\Cheat Engine, read-only; the installation was never modified", + "evidenceKind": "ExactInstalledFile", + "declaredIn": [ + "native/cheat-engine/README.md:32" + ] + }, + { + "subject": "native/cheat-engine/lua53-64.dll (committed fixture)", + "sha256": "c95dcdfa0f60f97b43d970d77fd1bb907af4de04b500a3c89a99600b20b35bd2", + "date": "2026-09-23", + "method": "SHA-256 of the committed file, recomputed by SupportProfileTests on every run", + "evidenceKind": "ExactBinary", + "declaredIn": [ + "native/cheat-engine/README.md:32", + "tests/CheatEngine.SDK.Lua.Interop.Tests/Fixture/BundledLuaLibraryTests.cs:17" + ] + }, + { + "subject": "ce.runtimeconfig.json (local modification, last written 2026-09-19)", + "sha256": "68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b", + "date": "2026-09-23", + "method": "Get-FileHash -Algorithm SHA256 on the installed file under %ProgramFiles%\\Cheat Engine, read-only; the installation was never modified", + "evidenceKind": "ExactInstalledFile", + "declaredIn": [] + }, + { + "subject": "celua.txt (Lua reference shipped with the installation)", + "sha256": "aa1342b4a5d5d5c65b255fb3a8fd7b6bcbbac1cd138961669d9f37f43e0b9c00", + "date": "2026-09-23", + "method": "Get-FileHash -Algorithm SHA256 on the installed file under %ProgramFiles%\\Cheat Engine, read-only; the installation was never modified", + "evidenceKind": "ExactInstalledFile", + "declaredIn": [] + }, + { + "subject": "gtutorial-i386.exe (authorized x86 target)", + "sha256": "9131b1ca916d6ac1fb67224a67cf0f578105d43aeeebb03137e94d73cbe11bca", + "date": "2026-09-23", + "method": "Get-FileHash -Algorithm SHA256 on the installed file under %ProgramFiles%\\Cheat Engine, read-only; the installation was never modified", + "evidenceKind": "ExactInstalledFile", + "declaredIn": [] + } + ] +} diff --git a/docs/qualification/support-profile.md b/docs/qualification/support-profile.md index 1c42f6d8..5c4b2007 100644 --- a/docs/qualification/support-profile.md +++ b/docs/qualification/support-profile.md @@ -2,6 +2,154 @@ > Recreated 2026-09 from the audit, not the historical documentations/ tree. -Exact Cheat Engine host, Lua module, `celua.txt` and runtime-configuration identities of each supported profile. +This page explains [`support-profile.json`](support-profile.json), the machine-readable list of the Cheat Engine host +profiles a qualification result can name. It follows the v0 schema +[`schemas/support-profile.v0.schema.json`](schemas/support-profile.v0.schema.json) and is checked by +`SupportProfileTests` in `tests/CheatEngine.SDK.Repository.Tests/Qualification`. Nothing here is a qualification +result: results live in the [matrix](README.md) and, for the exact host, in committed receipts. -Status: placeholder — content arrives with S-QUAL (V1) +## Profiles + +| Profile id | Kind | Qualifiable | Status | Evidence kind | What it identifies | +|--------------------------------------|-------------|-------------|-------------|----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ce-public-src-ec45d5f` | Documentary | no, never | NotExecuted | `ObservedSource` | The public `cheat-engine/cheat-engine` source at `ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37`. It declares 7.5.1 and the historical string/CLR managed bootstrap. It explains behaviour; it proves nothing about the 7.7 binary. | +| `ce-7.7.0.10621-x64-managed-hostfxr` | Qualifiable | yes | NotExecuted | `ExactInstalledFile` | `cheatengine-x86_64.exe` 7.7.0.10621, machine AMD64, SHA-256 `9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d`, loading managed plugins through nethost/hostfxr. | + +The qualifiable profile also pins: + +| Item | Value | +|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| +| Lua module | `lua53-64.dll`, SHA-256 `c95dcdfa0f60f97b43d970d77fd1bb907af4de04b500a3c89a99600b20b35bd2`, the same bytes as the committed `native/cheat-engine/lua53-64.dll` | +| Lua reference | `celua.txt`, SHA-256 `aa1342b4a5d5d5c65b255fb3a8fd7b6bcbbac1cd138961669d9f37f43e0b9c00`, the reference the audit analysed | +| Load profile | `managed-hostfxr`: the plugin is a framework-dependent .NET component started by Cheat Engine's nethost/hostfxr route | +| Runtime configuration | `ce.runtimeconfig.json`, SHA-256 `68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b`, `LocalModified` (see [Runtime policy](#runtime-policy)) | +| SDK plugin contract version | `6`, the value the SDK writes through `GetVersion` (`libs/CheatEngine.SDK.Abi/AbiConstants.cs`). It is not the Cheat Engine product version 7.7. | +| Qualified backends | `LocalProcess` only. A file opened as a process and CEServer produce different evidence and are not qualified (scenarios Q30.c and Q30.d). | +| Authorized targets | `tests/CheatEngine.SDK.QualificationTarget` for x64 and x86, published per run with its hash in each receipt; the installation's `gtutorial-i386.exe`, SHA-256 `9131b1ca916d6ac1fb67224a67cf0f578105d43aeeebb03137e94d73cbe11bca`, copied from the sandbox and never committed | +| Plugin architecture | x64 only. A result on this profile authorizes no x86 or ARM64 plugin claim. | + +Excluded executables of the same installation: `cheatengine-x86_64-SSE4-AVX2.exe` (same FileVersion, different binary, +SHA-256 `9d861d651ab9d1dc3c09ae34c8ed5dee3d1a29b080784c3c48773494c9350230`, not profiled), the launcher +`Cheat Engine.exe` (FileVersion 6.3.0.0, SHA-256 `5313618d93640bb29b66baadf2339de85e593a51715290dadece6d58e039a75e`, +never used) and the x86 host `cheatengine-i386.exe` (SHA-256 +`0a4b63eadbe824bcc5095a97ccdd8c580573ed72361514025abe1bf25f1387c9`, unsupported). + +## Identity separation + +These identities are never merged under a label such as "the latest Cheat Engine" (audit analyses/01, identity +table). + +| Object | Identity | What it can establish | +|---------------------------------|----------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------| +| Public Cheat Engine source | `cheat-engine/cheat-engine` @ `ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37` (declares 7.5.1) | The behaviour of that source tree, profile `ce-public-src-ec45d5f` | +| Controlled Cheat Engine binary | `cheatengine-x86_64.exe` 7.7.0.10621, SHA-256 `9727076d…6eb4d` | Host behaviour, only through committed receipts on profile `ce-7.7.0.10621-x64-managed-hostfxr` | +| SDK source under qualification | The tree hash (`git rev-parse HEAD^{tree}`), pull request and head SHA each receipt records | The code a receipt ran; with squash merges the tree hash, not a branch commit, is the durable identity | +| SDK release tag `v1.0.0` | Commit `a6fefb93e9c6f85a1bcedb68bf97e6741175b227`, tree `41678f939547b2215e106ee3bbc8c2878815652e` | The source the 1.0.0 package was built from; not by itself a proof of the package content | +| SDK package consumed by the Client | `CheatEngine.SDK` 1.0.0, range `[1.0.0, 2.0.0)` | The package the Client builds against today (see the tuples below) | +| Lua reference | `celua.txt` SHA-256 `aa1342b4a5d5d5c65b255fb3a8fd7b6bcbbac1cd138961669d9f37f43e0b9c00` | The documentary Lua reference; not the functions a running host exposes | + +## Package tuples + +A compatibility result is tied to a tuple: package, native bridge, Lua module, exact host, runtime policy and load +profile (audit analyses/21, "Le tuple à qualifier"). Two tuples exist today and are kept apart. + +**SDK-branch tuple.** The 2.0.0-alpha `CheatEngine.SDK` package built by the pull-request CI from the tree under +qualification. Each receipt records its identity: `nupkgSha256`, the NuGet content hash (SHA-512, base64) and the CI run. +Local packs are never qualification inputs. Its checked-in native bridge, +`native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll`, has SHA-256 +`889dc4c231d182f9b7baa9e29880555aad949f42023dde232fe327542c3c5387` and source fingerprint +`3342be23f88976d9209a24bc0d8b9db512482a24d8db90381a836ea4f5595a56:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8` +(SHA-256 of `cheatengine_sdk_lua_bridge.c` and of `xmake.lua`). When the bridge changes, this paragraph changes with it: +`SupportProfileTests` recomputes both values. + +**Client-consumed SDK 1.0.0 tuple.** Three distinct package identities, each identifying something different: + +| Identity | Value | What it identifies | +|---------------------------------------|----------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------| +| Attested GitHub release asset SHA-256 | `99bf90101cd13e0183c94759e43badc6a1e719ffc3e2c9fd0b93490abdac0632` | The unsigned `.nupkg` the release workflow built and attested | +| NuGet `contentHash` (SHA-512, base64) | `n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==` | What consumer lock files hold (`libs/CheatEngine.Client.Core/packages.lock.json`) | +| nuget.org repository-signed file SHA-256 | `3e8c98583ac71af25a5bd7053e7583fbafcd196139fae7c0b04bae9b40a7cd33` | The file nuget.org serves after adding its repository signature | + +Its bridge `build/native/cheatengine-sdk-lua-bridge.dll` has SHA-256 +`da08c2ba03019da3a8c432ef061d5d6133fd2169ba3a6a8e9ac903353856d994` and fingerprint +`8a63e00c7dd941212e7ef8c13d8c97f73142c5154bfbe5dbc5459e7131bb789b:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8`. +The Client's own qualification records this tuple; a result on one tuple does not transfer to the other without an +argued `transferJustification`. + +## Runtime policy + +The inspected Cheat Engine 7.7 binary starts managed plugins through nethost/hostfxr. The `ce.runtimeconfig.json` of the +profiled installation targets `net10.0` with `Microsoft.NETCore.App`, `Microsoft.WindowsDesktop.App` and +`Microsoft.AspNetCore.App` `10.0.0`, `rollForward` `LatestMinor`. It is a **local modification** (last written +2026-09-19, after the executable's 2026-06-16 date), not an installer baseline, and the installer's own configuration is +unknown. + +- Editing that file is never harmless or universal: it changes the runtime of every managed plugin of the installation. + Nothing in this repository edits an installed Cheat Engine, and no guide treats such an edit as a setup step. +- The qualification runner copies the installation into a sandbox and records the runtime configuration hash in every + receipt; a different hash makes the run `NotApplicable`, never `Passed`. +- .NET runtimes observed on the qualification machine: x64 `Microsoft.NETCore.App`, `Microsoft.WindowsDesktop.App` and + `Microsoft.AspNetCore.App` 10.0.8, 10.0.11 and 10.0.12, plus x64 `Microsoft.NETCore.App` and + `Microsoft.WindowsDesktop.App` 8.0.31; x86 `Microsoft.NETCore.App` and `Microsoft.WindowsDesktop.App` 6.0.36 only. A + framework-dependent `net10.0` x86 program therefore cannot run there, which is why the x86 qualification target is a + Native AOT executable. + +## Checkpoint A decisions + +The audit's Checkpoint A (analyses/22) closes three decisions. Each is a `ProposedDecision` dated 2026-09-23 until the +maintainers confirm it. + +| Id | Decision | +|-------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| CPA-1 | Supported profile: `ce-7.7.0.10621-x64-managed-hostfxr`, Cheat Engine 7.7.0.10621 x64 with the locally modified runtime configuration above, disclosed as such. An installed Cheat Engine is never edited: the runtime configuration applies to every managed plugin. | +| CPA-2 | 2.0 ships the managed hostfxr route only. The classic native plugin route (`CEPlugin_*` exports, classic exports table) stays documentary, and the NativeAOT plugin route is unsupported (F02, Q41, Q42): a publish that succeeds is not a Cheat Engine load or unload. | +| CPA-3 | Conditional and optional API families (debugger, DBVM, speedhack, CEServer and the other deferred families of analyses/18) stay in the catalogue with explicit statuses and are outside the qualified scope of 2.0 (ADR-04, ADR-11). | + +Consequences in the matrix: scenarios Q38 and Q39 are `NotApplicable` at C3 on this profile (no classic registration or +table route), Q42 is `NotApplicable` at C3 and C4 (no NativeAOT plugin route), and the file-as-process, CEServer, x86 +host and ARM sub-rows (Q30.c, Q30.d, Q32.c, Q32.d) are `NotApplicable` at C3. + +## Unsupported routes + +| Route | Why | +|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `historical-clr-loader` | The string-argument CLR bootstrap of the public source is a different load profile (ADR-02). The SDK never falls back to it. | +| `nativeaot-plugin` | A NativeAOT library cannot be unloaded with `FreeLibrary` ([Native AOT libraries](https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries)), which the classic loader calls. Unsupported until a residence model is qualified (F02, Q41, Q42). | +| `x86-host` | `cheatengine-i386.exe` is an x86 process; the SDK and its native bridge are x64-only (Q32.c). | +| `sse4-avx2-host-variant` | `cheatengine-x86_64-SSE4-AVX2.exe` has the same version but a different binary; it is not profiled, so a result obtained with it qualifies nothing. | + +## Registry + +Every copy of Cheat Engine, including the sandbox copy the runner starts and any instance the operator runs, reads and +writes the same `HKCU\Software\Cheat Engine` key. The profile lists, by name only, the subkeys and values that can +change a qualification run (for example `Plugins64`, where plugins registered through Settings > Plugins persist, and the +debugger and kernel-driver switches). Registry data is private: no export, value or datum is ever committed. The runner +exports the key before and after a run, records the difference as counts and value names in the receipt, and restores +it only when the difference is non-empty and no other Cheat Engine instance runs. + +## Measurement record + +The values above were measured on 2026-09-23, read-only, with `Get-FileHash -Algorithm SHA256` on the installed files +under `%ProgramFiles%\Cheat Engine` (evidence kind `ExactInstalledFile`) and on the committed repository files +(`ExactBinary`). They are kept apart from values that repository files only declare: + +| Subject | Declared before by | Measured evidence kind | +|----------------------------------------------|------------------------------------------------------------------------------------------------------------|------------------------| +| `cheatengine-x86_64.exe` | `tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs`, `tests/CheatEngine.SDK.LivePlugin/README.md` | `ExactInstalledFile` | +| installed `lua53-64.dll` | `native/cheat-engine/README.md` | `ExactInstalledFile` | +| committed `native/cheat-engine/lua53-64.dll` | `native/cheat-engine/README.md`, `BundledLuaLibraryTests` | `ExactBinary`, recomputed by `SupportProfileTests` | +| `ce.runtimeconfig.json` | nothing (the LivePlugin guide only states that it is a local modification) | `ExactInstalledFile` | +| `celua.txt` | the audit's identity table | `ExactInstalledFile` | +| excluded executables, `gtutorial-i386.exe` | nothing | `ExactInstalledFile` | + +A measured file is still not a host observation. The executable hash becomes `ObservedHost` only in a dated receipt that +ran on it; until then every C3/C4 cell of the matrix is `NotExecuted`. + +## Not executed + +The list of missing evidence (audit Checkpoint A deliverable): every matrix row with at least one level still +`NotExecuted`. It is generated from [`matrix.json`](matrix.json) and checked by +`SupportProfileTests.Not_executed_section_equals_the_matrix`. + + + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs new file mode 100644 index 00000000..964f8b28 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs @@ -0,0 +1,269 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +namespace CheatEngine.SDK.Repository.Tests.Qualification; + +/// +/// docs/qualification/support-profile.json and its page: the documentary public-source profile can never be +/// qualified, the qualifiable CE 7.7 profile names its exact host, runtime and route, and every recorded hash equals +/// the repository file it describes. No test reads the Cheat Engine installation. +/// +public sealed class SupportProfileTests +{ + private const string CeluaSha256 = "aa1342b4a5d5d5c65b255fb3a8fd7b6bcbbac1cd138961669d9f37f43e0b9c00"; + private const string LuaFixture = "native/cheat-engine/lua53-64.dll"; + private const string BridgeDirectory = "native/cheatengine-sdk-lua-bridge"; + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + + private static JsonElement SupportProfile => QualificationDocuments.LoadJson(QualificationDocuments.SupportProfilePath); + + private static JsonElement Qualifiable => + SupportProfileRules.Find(SupportProfile, QualificationContract.QualifiableProfileId) + ?? throw new InvalidOperationException("The qualifiable profile is missing."); + + [Fact] + public void Support_profile_matches_its_v0_schema() + { + IReadOnlyList errors = SupportProfileRules.Validate(SupportProfile); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + Assert.True(SupportProfile.TryGetProperty("measurements", out JsonElement measurements) && + measurements.GetArrayLength() > 0, "The SDK profile keeps its dated measurement record."); + } + + [Fact] + public void Public_source_profile_is_documentary_and_never_qualifiable() + { + JsonElement documentary = SupportProfileRules.Find(SupportProfile, QualificationContract.DocumentaryProfileId) + ?? throw new InvalidOperationException("The documentary profile is missing."); + + Assert.Equal("Documentary", documentary.GetProperty("kind").GetString()); + Assert.False(documentary.GetProperty("qualifiable").GetBoolean()); + Assert.Equal("NotExecuted", documentary.GetProperty("qualificationStatus").GetString()); + Assert.Equal("ObservedSource", documentary.GetProperty("evidenceKind").GetString()); + Assert.Equal("ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37", + documentary.GetProperty("source").GetProperty("commit").GetString()); + + // Refusal proof: the schema and the rules reject any attempt to make it qualifiable. + Assert.NotEmpty(SupportProfileRules.Validate(MutateProfile(QualificationContract.DocumentaryProfileId, + static profile => profile["qualifiable"] = true))); + Assert.NotEmpty(SupportProfileRules.Validate(MutateProfile(QualificationContract.DocumentaryProfileId, + static profile => profile["qualificationStatus"] = "HostQualified"))); + Assert.NotEmpty(SupportProfileRules.Validate(MutateProfile(QualificationContract.DocumentaryProfileId, + static profile => profile["kind"] = "Qualifiable"))); + } + + [Fact] + public void Qualifiable_profile_names_the_managed_hostfxr_route_and_the_local_runtimeconfig_modification() + { + JsonElement profile = Qualifiable; + JsonElement runtime = profile.GetProperty("runtime"); + JsonElement runtimeconfig = runtime.GetProperty("runtimeconfig"); + + Assert.Equal("Qualifiable", profile.GetProperty("kind").GetString()); + Assert.Equal("managed-hostfxr", runtime.GetProperty("loadProfile").GetString()); + Assert.Equal("LocalModified", runtimeconfig.GetProperty("classification").GetString()); + Assert.Equal("68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b", + runtimeconfig.GetProperty("sha256").GetString()); + Assert.Equal("net10.0", runtimeconfig.GetProperty("tfm").GetString()); + Assert.Equal( + ["Microsoft.NETCore.App 10.0.0 LatestMinor", "Microsoft.WindowsDesktop.App 10.0.0 LatestMinor", + "Microsoft.AspNetCore.App 10.0.0 LatestMinor"], + runtimeconfig.GetProperty("frameworks").EnumerateArray().Select(static framework => + framework.GetProperty("name").GetString() + " " + framework.GetProperty("version").GetString() + " " + + framework.GetProperty("rollForward").GetString()), StringComparer.Ordinal); + Assert.Equal(["LocalProcess"], + profile.GetProperty("qualifiedBackends").EnumerateArray().Select(static backend => backend.GetString()), StringComparer.Ordinal); + Assert.Equal(SdkPluginContractVersion(), profile.GetProperty("sdkPluginContractVersion").GetInt32()); + Assert.Equal("AMD64", profile.GetProperty("host").GetProperty("machine").GetString()); + Assert.Equal("HKCU\\Software\\Cheat Engine", profile.GetProperty("registry").GetProperty("key").GetString()); + Assert.Contains(profile.GetProperty("host").GetProperty("excludedVariants").EnumerateArray(), + static variant => string.Equals(variant.GetProperty("exeName").GetString(), "cheatengine-x86_64-SSE4-AVX2.exe", StringComparison.Ordinal)); + } + + [Fact] + public void Profile_lua_hash_equals_the_committed_fixture_dll() + { + string fixture = QualificationDocuments.RawSha256(LuaFixture); + + Assert.Equal(fixture, Qualifiable.GetProperty("lua").GetProperty("sha256").GetString()); + Assert.Contains(fixture.ToUpperInvariant(), QualificationDocuments.ReadNormalizedText("native/cheat-engine/README.md"), + StringComparison.Ordinal); + } + + [Fact] + public void Profile_host_hash_and_version_equal_the_LiveProbe_authorization_constants() + { + string source = QualificationDocuments.ReadNormalizedText( + "tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs"); + JsonElement host = Qualifiable.GetProperty("host"); + + Assert.Equal(host.GetProperty("exeSha256").GetString(), + Constant(source, "ExactCheatEngineSha256").ToLowerInvariant()); + Assert.Equal(host.GetProperty("version").GetString(), Constant(source, "ExactCheatEngineFileVersion")); + Assert.Equal("cheatengine-x86_64.exe", host.GetProperty("exeName").GetString()); + } + + [Fact] + public void Profile_celua_hash_is_the_audit_reference() + { + foreach (JsonElement profile in SupportProfile.GetProperty("profiles").EnumerateArray()) + { + Assert.Equal(CeluaSha256, profile.GetProperty("celua").GetProperty("sha256").GetString()); + } + } + + [Fact] + public void Markdown_tuple_section_names_the_checked_in_bridge_hash_and_fingerprint() + { + string bridge = QualificationDocuments.RawSha256(BridgeDirectory + + "/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"); + string fingerprint = QualificationDocuments.RawSha256(BridgeDirectory + "/cheatengine_sdk_lua_bridge.c") + ":" + + QualificationDocuments.RawSha256(BridgeDirectory + "/xmake.lua"); + string tuples = Section(QualificationDocuments.ReadNormalizedText(QualificationDocuments.SupportProfileMarkdownPath), + "## Package tuples"); + + Assert.Contains(bridge, tuples, StringComparison.Ordinal); + Assert.Contains(fingerprint, tuples, StringComparison.Ordinal); + } + + [Fact] + public void Checkpoint_A_decisions_appear_in_json_and_markdown() + { + string section = Section(QualificationDocuments.ReadNormalizedText(QualificationDocuments.SupportProfileMarkdownPath), + "## Checkpoint A decisions"); + List ids = []; + foreach (JsonElement decision in SupportProfile.GetProperty("decisions").EnumerateArray()) + { + string id = decision.GetProperty("id").GetString()!; + ids.Add(id); + Assert.Equal("ProposedDecision", decision.GetProperty("evidenceKind").GetString()); + Assert.Contains("| " + id + " |", section, StringComparison.Ordinal); + } + + Assert.Equal(["CPA-1", "CPA-2", "CPA-3"], ids); + Assert.Equal(ids.Count, Regex.Count(section, @"^\| CPA-\d+ \|", RegexOptions.Multiline, RegexTimeout)); + } + + [Fact] + public void Unsupported_routes_include_nativeaot_x86_host_and_sse4_variant() + { + List routes = + [.. SupportProfile.GetProperty("unsupportedRoutes").EnumerateArray().Select(static route => route.GetProperty("id").GetString()!)]; + string section = Section(QualificationDocuments.ReadNormalizedText(QualificationDocuments.SupportProfileMarkdownPath), + "## Unsupported routes"); + + Assert.Equal(["historical-clr-loader", "nativeaot-plugin", "x86-host", "sse4-avx2-host-variant"], routes); + foreach (string route in routes) + { + Assert.Contains("`" + route + "`", section, StringComparison.Ordinal); + } + } + + [Fact] + public void Qualification_documents_contain_no_absolute_local_path_or_global_percentage() + { + List problems = []; + foreach (string file in QualificationFiles()) + { + string text = QualificationDocuments.ReadNormalizedText(file); + if (TextRules.ContainsAbsoluteLocalPath(text)) + { + problems.Add(file + ": contains an absolute local path."); + } + + if (TextRules.ContainsPercentage(text)) + { + problems.Add(file + ": contains a percentage; qualification documents publish no global score."); + } + + foreach (string claim in (string[]) ["fully supported", "complete coverage", "full coverage"]) + { + if (text.Contains(claim, StringComparison.OrdinalIgnoreCase)) + { + problems.Add($"{file}: claims '{claim}'."); + } + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + public void Json_documents_are_in_canonical_form() + { + List documents = [QualificationDocuments.SupportProfilePath]; + if (QualificationDocuments.Exists(QualificationDocuments.MatrixPath)) + { + documents.Add(QualificationDocuments.MatrixPath); + } + + foreach (string schema in QualificationContract.SchemaFiles) + { + documents.Add(QualificationDocuments.SchemaDirectory + "/" + schema); + } + + foreach (string document in documents) + { + string committed = QualificationDocuments.ReadNormalizedText(document); + string canonical = QualificationDocuments.Canonical(QualificationDocuments.ParseJson(committed)); + Assert.True(string.Equals(committed, canonical, StringComparison.Ordinal), + $"{document} is not in canonical form (2-space indent, LF, unescaped non-ASCII, one final newline). Expected:{Environment.NewLine}{canonical}"); + } + } + + internal static string Section(string markdown, string heading) + { + int start = markdown.IndexOf("\n" + heading + "\n", StringComparison.Ordinal); + Assert.True(start >= 0, $"The heading '{heading}' is missing."); + int end = markdown.IndexOf("\n## ", start + heading.Length + 1, StringComparison.Ordinal); + return end < 0 ? markdown[start..] : markdown[start..end]; + } + + private static IEnumerable QualificationFiles() + { + foreach (string file in Directory.EnumerateFiles( + QualificationDocuments.Absolute(QualificationDocuments.QualificationDirectory), "*.*", + SearchOption.AllDirectories)) + { + if (file.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || + file.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + yield return Infrastructure.RepositoryRoot.ToRelative(file); + } + } + } + + private static int SdkPluginContractVersion() + { + string source = QualificationDocuments.ReadNormalizedText("libs/CheatEngine.SDK.Abi/AbiConstants.cs"); + Match match = Regex.Match(source, @"\bSdkVersion\s*=\s*(?\d+)\s*;", RegexOptions.None, RegexTimeout); + Assert.True(match.Success, "AbiConstants.SdkVersion was not found."); + return int.Parse(match.Groups["value"].Value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static string Constant(string source, string name) + { + Match match = Regex.Match(source, "\\b" + name + "\\s*=\\s*\"(?[^\"]+)\"", RegexOptions.None, + RegexTimeout); + Assert.True(match.Success, $"LiveProbeAuthorization.{name} was not found."); + return match.Groups["value"].Value; + } + + private static JsonElement MutateProfile(string id, Action change) + { + JsonNode document = JsonNode.Parse(SupportProfile.GetRawText())!; + foreach (JsonNode? profile in document["profiles"]!.AsArray()) + { + if (string.Equals((string?) profile!["id"], id, StringComparison.Ordinal)) + { + change(profile.AsObject()); + } + } + + return QualificationDocuments.ParseJson(document.ToJsonString()); + } +} From 458b27eb1e1ce105bb9390a0b425f123df3a1522 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:43:43 +0200 Subject: [PATCH 024/199] Prove the receipt contract with accepted and refused samples A receipt is only evidence when it names everything the result depends on (A20-03, A20-09, A04-22, ADR-12). Add QualificationReceiptTests with a complete sample receipt and event log as raw string literals, and refusal proofs for each rule the audit and the plan state: - citing the documentary public-source profile (A00-05, A17-03); - any drive, user-profile or file:// path, so receipts stay distributable (F14); - a Passed receipt without passKind, and a pass kind on a non-passed receipt (A20-11); - a missing tree hash, package hash, content hash or pull request field (A20-05, PR-SEQ-19); - a local pack instead of the CI artifact or nuget.org, and a CI artifact without its run; - an id that does not encode start time, scenario and package, and a fixture-level receipt; - a run on another host unless recorded as a justified NotApplicable. Committed receipts, none today, must validate where they are committed, match their event log's LF-normalized hash, carry no local path or raw debug output, and be cited by the matrix cell they qualify. --- .../QualificationReceiptTests.cs | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationReceiptTests.cs diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationReceiptTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationReceiptTests.cs new file mode 100644 index 00000000..57faa86b --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationReceiptTests.cs @@ -0,0 +1,327 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +using CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +namespace CheatEngine.SDK.Repository.Tests.Qualification; + +/// +/// The v0 receipt contract: a complete, redacted C3/C4 receipt of the exact host and CI package is accepted; a +/// receipt that cites the documentary profile, leaks a local path, omits its pass kind or its tree and package +/// identity, or comes from a local pack is refused. Committed receipts are also checked where they are committed. +/// +public sealed class QualificationReceiptTests +{ + private const string SampleReceipt = + """ + { + "schema": "cheatengine-qualification-receipt/v0", + "receiptId": "R-20260924T101530Z-Q04-bfa967cc", + "qualificationId": "Q04", + "level": "C3", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "operator": "AriusII", + "loadRoute": "LuaLoadPlugin", + "repository": { + "name": "CheatEngineNet/CheatEngine.SDK", + "treeHash": "40d7d7f741856c7e372e94bbd8532b06651eff5b", + "commit": "e77fb34c1f4e9c0e9d0e4a4a3b6c7d8e9f0a1b2c", + "pullRequest": { "number": 86, "headSha": "e77fb34c1f4e9c0e9d0e4a4a3b6c7d8e9f0a1b2c" } + }, + "runner": { + "script": "eng/qualification/Invoke-LocalQualification.ps1", + "scriptSha256": "21a0270b7f66a1e4c25933f13a1e5a1bbb4757578072930c8189131f9c6aaae1", + "sourceRepository": "CheatEngineNet/CheatEngine.SDK", + "sourceCommit": "e77fb34c1f4e9c0e9d0e4a4a3b6c7d8e9f0a1b2c", + "mutex": "Global\\ce-lab" + }, + "package": { + "id": "CheatEngine.SDK", + "version": "2.0.0-alpha.0.12", + "nupkgSha256": "bfa967cc650ad859e5fd3164b53ae2081b6165fd5f2fa16af08b89621dfb70a4", + "contentHashSha512": "DOHZH8TYAm/L/qVXbpPlnoHh34r0GPB9erQYfBy0gyG84KP2iPVW6tFiuSX89K825zxz2u/S21JcFADMQVaUKw==", + "source": "CiArtifact", + "ciRunUrl": "https://github.com/CheatEngineNet/CheatEngine.SDK/actions/runs/1234567890" + }, + "host": { + "ceExeName": "cheatengine-x86_64.exe", + "ceExeSha256": "9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d", + "ceFileVersion": "7.7.0.10621", + "luaDllSha256": "c95dcdfa0f60f97b43d970d77fd1bb907af4de04b500a3c89a99600b20b35bd2", + "runtimeconfigSha256": "68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b", + "autorunSha256": "b4def8217cadae26d4da633fd2a4e58e326cbb5d570afdc3989484da07af3579", + "sandboxCopy": true, + "osVersion": "Microsoft Windows NT 10.0.26200.0", + "dotnetRuntimes": [ "Microsoft.NETCore.App 10.0.12 x64", "Microsoft.WindowsDesktop.App 10.0.12 x64" ] + }, + "bridge": { + "sha256": "889dc4c231d182f9b7baa9e29880555aad949f42023dde232fe327542c3c5387", + "sourceFingerprint": "3342be23f88976d9209a24bc0d8b9db512482a24d8db90381a836ea4f5595a56:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8" + }, + "bundles": [ + { + "name": "LiveProbe", + "manifestSha256": "1e6ed65d77d6364eeaed5a745ba5c4985ae2b700dd85d7cf7f027bdf294a33fc", + "files": [ + { "path": "CheatEngine.SDK.LiveProbe.dll", "sha256": "5e689e2b01672bf33996e75d5e372ff60c536ce1599a1458e867cd8f4bef5160" }, + { "path": "cheatengine-sdk-lua-bridge.dll", "sha256": "889dc4c231d182f9b7baa9e29880555aad949f42023dde232fe327542c3c5387" } + ] + } + ], + "target": { + "kind": "QualificationTarget", + "arch": "x64", + "sha256": "34a04005bcaf206eec990bd9637d9fdb6725e0a0c0d4aebf003f17f4c956eb5c" + }, + "registry": { + "key": "HKCU\\Software\\Cheat Engine", + "exportBeforeSha256": "ed72904e38a86ce1c168326fb13f14acf5655d8b82e94fdb74673f7f9b030b71", + "exportAfterSha256": "ed72904e38a86ce1c168326fb13f14acf5655d8b82e94fdb74673f7f9b030b71", + "restored": false, + "diff": { "added": 0, "removed": 0, "changed": 0, "valueNames": [] } + }, + "preconditions": [ "LiveProbe loaded from the exact CI package through loadPlugin." ], + "operation": "Load the plugin and read ce77_live_probe_status_json().", + "expected": "The raw second bootstrap integer is recorded without interpretation.", + "observed": "bootstrap.opaqueSecondInt = 0; interpretation none.", + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedHost", + "justification": null, + "timings": { + "startedUtc": "2026-09-24T10:15:30Z", + "finishedUtc": "2026-09-24T10:15:41Z", + "durationMs": 11000, + "ceStartMs": 2100 + }, + "eventLog": { + "path": "R-20260924T101530Z-Q04-bfa967cc.events.json", + "sha256": "862417b9e7c3720bcb3263cd873b09892d787823b6f9a0f453e42824c5a4d4b6", + "format": "cheatengine-qualification-events/v0", + "redactions": [ "", "", "" ] + }, + "transferJustification": null, + "createdUtc": "2026-09-24T10:15:42Z" + } + """; + + private const string SampleEvents = + """ + { + "schema": "cheatengine-qualification-events/v0", + "receiptId": "R-20260924T101530Z-Q04-bfa967cc", + "events": [ + { "tMs": 0, "source": "Runner", "kind": "Preflight", "message": "Host, Lua module and runtime configuration equal profile ce-7.7.0.10621-x64-managed-hostfxr." }, + { "tMs": 2100, "source": "Driver", "kind": "LoadPlugin", "message": "loadPlugin('/CheatEngine.SDK.LiveProbe.dll') returned 0." }, + { "tMs": 2400, "source": "Plugin", "kind": "LuaResult", "message": "{\"schema\":\"ce77-live-probe-status-v1\",\"bootstrap\":{\"opaqueSecondInt\":0,\"interpretation\":\"none\"}}" }, + { "tMs": 9800, "source": "Driver", "kind": "CloseCE", "message": "closeCE() requested." } + ] + } + """; + + private static JsonElement SupportProfile => QualificationDocuments.LoadJson(QualificationDocuments.SupportProfilePath); + + [Fact] + public void A_complete_sample_receipt_is_accepted() + { + JsonElement receipt = QualificationDocuments.ParseJson(SampleReceipt); + + IReadOnlyList errors = ReceiptRules.Validate(receipt, SupportProfile); + IReadOnlyList eventErrors = ReceiptRules.ValidateEventLog(QualificationDocuments.ParseJson(SampleEvents), + "R-20260924T101530Z-Q04-bfa967cc"); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + Assert.True(eventErrors.Count == 0, string.Join(Environment.NewLine, eventErrors)); + } + + [Fact] + public void A_receipt_citing_the_public_source_profile_is_refused() + { + IReadOnlyList errors = Validate(static receipt => + receipt["profileId"] = QualificationContract.DocumentaryProfileId); + + Assert.Contains(errors, static error => error.Contains("never qualifiable", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("observed", @"Loaded from C:\Users\someone\ce\CheatEngine.SDK.LiveProbe.dll.")] + [InlineData("observed", "Loaded from d:/CheatEngine/bundles/plugin.dll.")] + [InlineData("operation", "Open file:///c:/sandbox/cheatengine-x86_64.exe.")] + [InlineData("expected", @"Nothing is read from \Users\someone.")] + public void A_receipt_with_an_absolute_local_path_is_refused(string field, string text) + { + IReadOnlyList errors = Validate(receipt => receipt[field] = text); + + Assert.Contains(errors, static error => error.Contains("absolute local path", StringComparison.Ordinal)); + } + + [Fact] + public void A_passed_receipt_without_a_pass_kind_is_refused() + { + IReadOnlyList missing = Validate(static receipt => receipt.Remove("passKind")); + IReadOnlyList nulled = Validate(static receipt => receipt["passKind"] = null); + IReadOnlyList notApplicableWithPassKind = Validate(static receipt => + { + receipt["status"] = "NotApplicable"; + receipt["justification"] = "The run used another host."; + }); + + Assert.Contains(missing, static error => error.Contains("'passKind'", StringComparison.Ordinal)); + Assert.NotEmpty(nulled); + Assert.Contains(notApplicableWithPassKind, static error => error.Contains("/passKind", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("repository", "treeHash")] + [InlineData("package", "nupkgSha256")] + [InlineData("package", "contentHashSha512")] + [InlineData("repository", "pullRequest")] + public void A_host_level_receipt_without_tree_and_package_identity_is_refused(string section, string field) + { + IReadOnlyList errors = Validate(receipt => receipt[section]!.AsObject().Remove(field)); + + Assert.Contains(errors, error => error.Contains("'" + field + "'", StringComparison.Ordinal)); + } + + [Fact] + public void A_receipt_whose_package_source_is_a_local_pack_is_refused() + { + IReadOnlyList localPack = Validate(static receipt => receipt["package"]!["source"] = "LocalPack"); + IReadOnlyList artifactWithoutRun = Validate(static receipt => receipt["package"]!["ciRunUrl"] = null); + + Assert.Contains(localPack, static error => error.Contains("/package/source", StringComparison.Ordinal)); + Assert.Contains(artifactWithoutRun, static error => error.Contains("/package/ciRunUrl", StringComparison.Ordinal)); + } + + [Fact] + public void Receipt_id_encodes_its_time_and_qualification_id() + { + JsonElement sample = QualificationDocuments.ParseJson(SampleReceipt); + + IReadOnlyList otherTime = Validate(static receipt => + receipt["timings"]!["startedUtc"] = "2026-09-24T10:15:31Z"); + IReadOnlyList otherScenario = Validate(static receipt => receipt["qualificationId"] = "Q05"); + IReadOnlyList otherPackage = Validate(static receipt => + receipt["package"]!["nupkgSha256"] = "0000000000000000000000000000000000000000000000000000000000000000"); + + Assert.Equal("R-20260924T101530Z-Q04-bfa967cc", ReceiptRules.ExpectedReceiptId(sample)); + Assert.Contains(otherTime, static error => error.Contains("R-20260924T101531Z-Q04-bfa967cc", StringComparison.Ordinal)); + Assert.Contains(otherScenario, static error => error.Contains("R-20260924T101530Z-Q05-bfa967cc", StringComparison.Ordinal)); + Assert.Contains(otherPackage, static error => error.Contains("R-20260924T101530Z-Q04-00000000", StringComparison.Ordinal)); + } + + [Fact] + public void A_fixture_level_receipt_is_refused() + { + IReadOnlyList errors = Validate(static receipt => receipt["level"] = "C2"); + + Assert.Contains(errors, static error => error.Contains("/level", StringComparison.Ordinal)); + } + + [Fact] + public void A_run_on_another_host_is_accepted_only_as_a_justified_not_applicable_receipt() + { + const string OtherHost = "9d861d651ab9d1dc3c09ae34c8ed5dee3d1a29b080784c3c48773494c9350230"; + + IReadOnlyList passed = Validate(static receipt => receipt["host"]!["ceExeSha256"] = OtherHost); + IReadOnlyList notApplicable = Validate(static receipt => + { + receipt["host"]!["ceExeSha256"] = OtherHost; + receipt["status"] = "NotApplicable"; + receipt["passKind"] = null; + receipt["justification"] = "Preflight: the executable is the SSE4-AVX2 variant, which is not profiled."; + }); + IReadOnlyList unjustified = Validate(static receipt => + { + receipt["host"]!["ceExeSha256"] = OtherHost; + receipt["status"] = "NotApplicable"; + receipt["passKind"] = null; + }); + + Assert.Contains(passed, static error => error.Contains("host.ceExeSha256", StringComparison.Ordinal)); + Assert.True(notApplicable.Count == 0, string.Join(Environment.NewLine, notApplicable)); + Assert.Contains(unjustified, static error => error.Contains("/justification", StringComparison.Ordinal)); + } + + [Fact] + public void Every_committed_receipt_is_valid_and_its_event_log_hash_matches() + { + List errors = []; + foreach (string receipt in QualificationDocuments.CommittedReceipts()) + { + errors.AddRange(ReceiptRules.ValidateCommitted(receipt, SupportProfile)); + } + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + } + + [Fact] + public void Committed_event_logs_contain_no_user_path_or_raw_debug_output() + { + // The detectors are proven on synthetic lines first, so an empty receipts folder cannot hide a broken rule. + Assert.True(TextRules.ContainsRawDebugOutput("00000042\t12.50000000\t[12345] [CheatEngine.SDK.Hosting] x")); + Assert.True(TextRules.ContainsRawDebugOutput("[CheatEngine.SDK.Hosting] Information: Plugin 7 enabled.")); + Assert.False(TextRules.ContainsRawDebugOutput("{\"schema\":\"ce77-live-probe-status-v1\"}")); + Assert.True(TextRules.ContainsAbsoluteLocalPath(@"C:\Users\someone\AppData\Local")); + Assert.False(TextRules.ContainsAbsoluteLocalPath("/bundles/LiveProbe and https://github.com/x")); + + List problems = []; + foreach (string log in QualificationDocuments.CommittedEventLogs()) + { + JsonElement events = QualificationDocuments.LoadJson(log); + foreach (string path in TextRules.AbsoluteLocalPaths(events)) + { + problems.Add($"{log}: unredacted local path at {path}"); + } + + foreach (JsonElement item in events.GetProperty("events").EnumerateArray()) + { + if (TextRules.ContainsRawDebugOutput(item.GetProperty("message").GetString() ?? string.Empty)) + { + problems.Add($"{log}: raw debug output at tMs {item.GetProperty("tMs").GetInt64()}"); + } + } + } + + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + public void Every_committed_receipt_is_referenced_by_the_matrix_cell_it_qualifies() + { + IReadOnlyList receipts = QualificationDocuments.CommittedReceipts(); + if (receipts.Count == 0) + { + return; + } + + QualificationMatrix matrix = QualificationMatrix.Read(QualificationDocuments.LoadJson(QualificationDocuments.MatrixPath)); + List unreferenced = []; + foreach (string path in receipts) + { + JsonElement receipt = QualificationDocuments.LoadJson(path); + string receiptId = receipt.GetProperty("receiptId").GetString()!; + QualificationMatrix.Row? row = matrix.Find(receipt.GetProperty("qualificationId").GetString()!); + bool referenced = row is not null && + row.Levels.TryGetValue(receipt.GetProperty("level").GetString()!, + out QualificationMatrix.Cell? cell) && + cell.Evidence.Any(evidence => + string.Equals(evidence.ReceiptId, receiptId, StringComparison.Ordinal) && + string.Equals(evidence.Path, path, StringComparison.Ordinal)); + if (!referenced) + { + unreferenced.Add(path); + } + } + + Assert.True(unreferenced.Count == 0, + "Commit the matrix cell update together with these receipts: " + string.Join(", ", unreferenced)); + } + + private static IReadOnlyList Validate(Action change) + { + JsonObject receipt = JsonNode.Parse(SampleReceipt)!.AsObject(); + change(receipt); + return ReceiptRules.Validate(QualificationDocuments.ParseJson(receipt.ToJsonString()), SupportProfile); + } +} From 2e88b9d6c7fd93913d97ba48bb88f8fdb844bd76 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:45:52 +0200 Subject: [PATCH 025/199] Tag existing C0-C2 evidence with Qualification traits The audit executed nothing, and the existing tests are resources, not results, until each one is mapped to the scenario it evidences (A20-01, QUAL-MAT-2). Add only [Trait("Qualification", "Qxx")] attribute lines to the test methods whose assertions match the scenario's success criterion in analyses/20: Q01, Q02, Q03, Q04, Q05, Q05.a, Q06, Q07, Q08, Q11, Q12, Q14, Q15, Q16, Q17 and Q39. Every tagged test was read against its criterion first. Three candidates stay untagged because they do not prove their scenario: DisablePluginTests.OnDisable_throwing_is_logged_but_reports_TRUE_after_the_plugin_is_disabled (TRUE after a failed plugin cleanup is the opposite of Q08's "no false cleanly disabled" evidence), and ReentrancyTests Enable_nested_in_OnEnable_is_refused_and_does_not_replace_the_context and Enable_nested_in_OnDisable_is_refused_and_the_disable_stands (nested enables, not the re-entrant disable of Q07). InitializeManagedTests Second_call_is_idempotent_and_writes_the_same_bytes_including_the_name_pointer joins Q05 because it proves the stable-name half of that scenario. The four modules pass with --fail-skips on, and --filter-trait "Qualification=Q07" selects exactly the five Q07 methods. --- .../Fixture/NativeAbiFixtureContractTests.cs | 2 ++ .../Managed/PluginInitRecordTests.cs | 3 +++ .../Native/ClassicExportedFunctionsPrefixReaderTests.cs | 5 +++++ .../Native/PluginVersionTests.cs | 3 +++ .../Bootstrap/GetVersionTests.cs | 1 + .../Bootstrap/InitializeManagedTests.cs | 5 +++++ .../Lifecycle/DisablePluginTests.cs | 4 ++++ .../Lifecycle/EnablePluginTests.cs | 9 +++++++++ .../Lifecycle/ReentrancyTests.cs | 3 +++ .../Loading/LuaModuleTests.cs | 1 + .../Callbacks/LuaCallbackTests.cs | 6 ++++++ .../Protected/CheckStackFailureProcessTests.cs | 1 + .../Protected/NativeFailureProcessTests.cs | 1 + .../References/LuaRefEpochTests.cs | 2 ++ .../CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs | 1 + .../Registration/LuaRegistrationSetTests.cs | 3 +++ .../CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs | 1 + 17 files changed, 51 insertions(+) diff --git a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs index acc51c41..ee6b1dd6 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs @@ -13,6 +13,7 @@ namespace CheatEngine.SDK.Abi.Tests.Fixture; public sealed class NativeAbiFixtureContractTests { [Fact] + [Trait("Qualification", "Q01")] public void Header_derived_classic_records_have_the_fixture_x64_sizes() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -33,6 +34,7 @@ public void Header_derived_classic_records_have_the_fixture_x64_sizes() } [Fact] + [Trait("Qualification", "Q01")] public void Header_derived_classic_records_have_the_fixture_x64_alignments() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); diff --git a/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs index 4fc5157b..e7230545 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs @@ -16,6 +16,7 @@ public sealed unsafe class PluginInitRecordTests private static int s_disableCalls; [Fact] + [Trait("Qualification", "Q02")] public void Size_on_64_bit_is_36_bytes_packed() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -24,6 +25,7 @@ public void Size_on_64_bit_is_36_bytes_packed() } [Fact] + [Trait("Qualification", "Q02")] public void Field_offsets_on_64_bit_match_the_host_record() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -45,6 +47,7 @@ public void Field_offsets_on_64_bit_match_the_host_record() [Theory] [InlineData(16)] [InlineData(13)] + [Trait("Qualification", "Q02")] public void Write_through_a_pointer_on_64_bit_touches_exactly_36_bytes(int start) { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs index d3007679..d0ab6c42 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs @@ -12,6 +12,7 @@ namespace CheatEngine.SDK.Abi.Tests.Native; public sealed unsafe class ClassicExportedFunctionsPrefixReaderTests { [Fact] + [Trait("Qualification", "Q39")] public void TryCopy_rejects_an_empty_table_representation() { bool copied = @@ -22,6 +23,7 @@ public void TryCopy_rejects_an_empty_table_representation() } [Fact] + [Trait("Qualification", "Q39")] public void TryCopy_rejects_a_buffer_that_cannot_contain_the_declared_size_field() { Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DeclaredSizeByteCount - 1]; @@ -36,6 +38,7 @@ public void TryCopy_rejects_a_buffer_that_cannot_contain_the_declared_size_field [InlineData(0)] [InlineData(-1)] [InlineData(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount - 1)] + [Trait("Qualification", "Q39")] public void TryCopy_rejects_a_truncated_declared_table(int declaredSize) { Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount]; @@ -48,6 +51,7 @@ public void TryCopy_rejects_a_truncated_declared_table(int declaredSize) } [Fact] + [Trait("Qualification", "Q39")] public void TryCopy_rejects_a_physically_truncated_table_even_when_its_size_claim_is_sufficient() { Span table = stackalloc byte[ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount - 1]; @@ -62,6 +66,7 @@ public void TryCopy_rejects_a_physically_truncated_table_even_when_its_size_clai [Theory] [InlineData(ClassicExportedFunctionsPrefixReader.DirectPrefixByteCount)] [InlineData(int.MaxValue)] + [Trait("Qualification", "Q39")] public void TryCopy_copies_exactly_the_qualified_prefix_without_overflow(int declaredSize) { uint processId = 0x2468u; diff --git a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs index 2814d030..322ab11f 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs @@ -8,6 +8,7 @@ namespace CheatEngine.SDK.Abi.Tests.Native; public sealed unsafe class PluginVersionTests { [Fact] + [Trait("Qualification", "Q01")] public void Size_on_64_bit_is_16_bytes() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -16,6 +17,7 @@ public void Size_on_64_bit_is_16_bytes() } [Fact] + [Trait("Qualification", "Q01")] public void Field_offsets_on_64_bit_match_the_c_structure() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); @@ -27,6 +29,7 @@ public void Field_offsets_on_64_bit_match_the_c_structure() } [Fact] + [Trait("Qualification", "Q01")] public void Write_through_a_pointer_on_64_bit_leaves_the_padding_alone_and_places_the_name_at_8() { Assert.SkipUnless(Layout.Is64BitProcess, Layout.Requires64BitProcess); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs index ff76a04b..f781fcf9 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs @@ -9,6 +9,7 @@ namespace CheatEngine.SDK.Hosting.Tests.Bootstrap; public sealed unsafe class GetVersionTests { [Fact] + [Trait("Qualification", "Q01")] public void Fills_version_6_and_the_bootstrap_name_pointer() { HostingTest.Reset(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs index a1520ebd..55b861eb 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs @@ -15,6 +15,7 @@ namespace CheatEngine.SDK.Hosting.Tests.Bootstrap; public sealed unsafe class InitializeManagedTests { [Fact] + [Trait("Qualification", "Q02")] public void Writes_exactly_the_36_byte_record_and_nothing_past_it() { HostingTest.Reset(); @@ -40,6 +41,7 @@ public void Writes_exactly_the_36_byte_record_and_nothing_past_it() } [Fact] + [Trait("Qualification", "Q02")] public void Writes_the_record_at_an_odd_address_without_touching_the_guard() { HostingTest.Reset(); @@ -53,6 +55,7 @@ public void Writes_the_record_at_an_odd_address_without_touching_the_guard() } [Fact] + [Trait("Qualification", "Q05")] public void Second_call_is_idempotent_and_writes_the_same_bytes_including_the_name_pointer() { HostingTest.Reset(); @@ -81,6 +84,7 @@ public void Name_is_the_ASCII_bytes_of_the_factory_name_NUL_terminated() } [Fact] + [Trait("Qualification", "Q05.a")] public void Non_ASCII_name_is_converted_to_the_process_ANSI_code_page() { HostingTest.Reset(); @@ -129,6 +133,7 @@ public void Null_record_address_fails_and_is_logged() [InlineData(36)] [InlineData(40)] [InlineData(4096)] + [Trait("Qualification", "Q04")] public void An_opaque_bootstrap_argument_is_recorded_without_changing_the_record_write(int hostArgument) { HostingTest.Reset(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs index 9208ba07..ec13ea8f 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs @@ -59,6 +59,7 @@ public void Disable_from_a_non_main_thread_is_refused_without_starting_cleanup() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] public void Disable_from_an_admitted_Lua_operation_is_refused_without_changing_the_lifecycle() { HostingTest.RequireNativeLua(); @@ -81,6 +82,7 @@ public void Disable_from_an_admitted_Lua_operation_is_refused_without_changing_t [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] public void Disable_from_executing_dispatched_work_is_refused_without_waiting_for_that_work() { HostingTest.RequireNativeLua(); @@ -252,6 +254,7 @@ public void Disable_runs_OnDisable_while_attached_then_detaches_and_withdraws_th [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q15")] public void Disable_neutralizes_the_callbacks_the_plugin_forgot() { HostingTest.RequireNativeLua(); @@ -364,6 +367,7 @@ public void OnDisable_throwing_is_logged_but_reports_TRUE_after_the_plugin_is_di [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q05")] public void Enable_disable_enable_reuses_the_instance_and_attaches_with_a_new_epoch() { HostingTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs index 58779d5c..4f27b7c4 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs @@ -23,6 +23,7 @@ namespace CheatEngine.SDK.Hosting.Tests.Lifecycle; public sealed unsafe class EnablePluginTests { [Fact] + [Trait("Qualification", "Q03")] public void A_null_exports_record_fails_without_touching_Lua() { CapturingLogSink sink = HostingTest.Reset(); @@ -43,6 +44,7 @@ public void A_null_exports_record_fails_without_touching_Lua() [InlineData(40)] [InlineData(47)] [InlineData(-48)] + [Trait("Qualification", "Q03")] public void An_undersized_exports_record_fails_cleanly(int reportedSize) { CapturingLogSink sink = HostingTest.Reset(); @@ -61,6 +63,7 @@ public void An_undersized_exports_record_fails_cleanly(int reportedSize) } [Fact] + [Trait("Qualification", "Q03")] public void A_record_without_GetLuaState_fails() { CapturingLogSink sink = HostingTest.Reset(); @@ -88,6 +91,7 @@ public void Enable_before_the_bootstrap_fails() } [Fact] + [Trait("Qualification", "Q11")] public void Without_a_Lua_module_in_the_process_the_enable_fails_before_any_plugin_code() { CapturingLogSink sink = HostingTest.Reset(); @@ -224,6 +228,7 @@ public void A_state_provider_that_returns_null_fails_the_self_check() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q06")] public void OnEnable_throwing_makes_the_enable_fail_and_detaches_the_runtime() { HostingTest.RequireNativeLua(); @@ -251,6 +256,7 @@ public void OnEnable_throwing_makes_the_enable_fail_and_detaches_the_runtime() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q08")] public void OnEnable_failure_with_detach_failure_keeps_incomplete_cleanup_retryable() { HostingTest.RequireNativeLua(); @@ -299,6 +305,7 @@ public void OnEnable_failure_with_detach_failure_keeps_incomplete_cleanup_retrya [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q08")] public void A_failed_cleanup_retry_rejects_nested_disable_until_the_retry_unwinds() { HostingTest.RequireNativeLua(); @@ -356,6 +363,7 @@ public void A_failed_cleanup_retry_rejects_nested_disable_until_the_retry_unwind [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q06")] public void A_throwing_constructor_fails_the_enable_and_is_retried_on_the_next_enable() { HostingTest.RequireNativeLua(); @@ -423,6 +431,7 @@ public void Enabling_twice_without_a_disable_is_ignored_with_a_warning() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q11")] public void The_production_module_lookup_finds_the_fixture_when_it_is_Cheat_Engines_DLL() { HostingTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs index 1be9ec3f..51354137 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs +++ b/tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs @@ -25,6 +25,7 @@ public sealed unsafe class ReentrancyTests [Trait("Category", "NativeLua")] [SuppressMessage("xUnit.Analyzers", "xUnit1051", Justification = "The bounded lifecycle barrier is a deterministic host-thread synchronization point.")] + [Trait("Qualification", "Q07")] public void A_concurrent_disable_during_OnEnable_fails_immediately_and_the_outer_enable_decides_the_state() { HostingTest.RequireNativeLua(); @@ -81,6 +82,7 @@ public void A_concurrent_disable_during_OnEnable_fails_immediately_and_the_outer [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] public void Disable_nested_in_OnEnable_is_refused_and_the_enable_stands() { HostingTest.RequireNativeLua(); @@ -182,6 +184,7 @@ public void Enable_nested_in_OnDisable_is_refused_and_the_disable_stands() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q07")] public void Disable_nested_in_OnDisable_is_refused_and_OnDisable_runs_once() { HostingTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs index 512dbe25..f5d58c69 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs @@ -59,6 +59,7 @@ public void TryGetLoaded_default_name_agrees_with_the_explicit_name() [Fact] [Trait("Category", "NativeLua")] + [Trait("Qualification", "Q11")] public void TryGetLoaded_finds_the_lua_module_the_fixture_loaded_without_loading_another() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs index f0b43cb4..2eb6a62d 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs @@ -59,6 +59,7 @@ public void Generated_function_closure_retained_across_state_reset_is_rejected() } [Fact] + [Trait("Qualification", "Q14")] public void A_failure_reported_by_the_thunk_is_a_catchable_lua_error_with_the_message() { LuaTest.RequireNativeLua(); @@ -93,6 +94,7 @@ public void An_unprotected_call_of_a_failing_thunk_fails_the_enclosing_protected } [Fact] + [Trait("Qualification", "Q14")] public void A_managed_exception_inside_a_thunk_never_escapes_and_becomes_a_lua_error() { LuaTest.RequireNativeLua(); @@ -231,6 +233,7 @@ public void Two_callbacks_of_the_same_thunk_have_independent_state() } [Fact] + [Trait("Qualification", "Q15")] public void Releasing_a_callback_neutralizes_the_closure_a_script_kept() { LuaTest.RequireNativeLua(); @@ -265,6 +268,7 @@ public void Releasing_a_callback_neutralizes_the_closure_a_script_kept() } [Fact] + [Trait("Qualification", "Q15")] public void Detach_neutralizes_every_callback_the_plugin_forgot() { LuaTest.RequireNativeLua(); @@ -291,6 +295,7 @@ public void Detach_neutralizes_every_callback_the_plugin_forgot() } [Fact] + [Trait("Qualification", "Q17")] public void BeginStateReset_neutralizes_every_callback_before_the_state_is_replaced() { LuaTest.RequireNativeLua(); @@ -509,6 +514,7 @@ await Assert.ThrowsAsync(() => detach.WaitAsync(TimeS } [Fact] + [Trait("Qualification", "Q08")] public void Detach_cleanup_failure_keeps_remaining_callbacks_and_allows_a_retry() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs index b23e2120..ccc85edc 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs @@ -13,6 +13,7 @@ namespace CheatEngine.SDK.Lua.Tests.Protected; public sealed class CheckStackFailureProcessTests { [Fact] + [Trait("Qualification", "Q12")] public async Task Direct_checkstack_growth_with_a_rejecting_allocator_returns_zero_and_recovers() { Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs index e86af5ab..ab26cd3a 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs @@ -10,6 +10,7 @@ namespace CheatEngine.SDK.Lua.Tests.Protected; public sealed class NativeFailureProcessTests { [Fact] + [Trait("Qualification", "Q12")] public async Task Generated_function_PushClosure_failure_returns_status_and_restores_stack() { Assert.SkipUnless(NativeLuaLibrary.IsAvailable, NativeLuaLibrary.UnavailableReason); diff --git a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs index 631d5654..a33f737e 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs @@ -50,6 +50,7 @@ public void A_reference_bound_in_the_current_state_identity_is_current() } [Fact] + [Trait("Qualification", "Q17")] public void A_reference_from_another_epoch_is_resolved_but_stale() { LuaRef reference = new(); @@ -62,6 +63,7 @@ public void A_reference_from_another_epoch_is_resolved_but_stale() } [Fact] + [Trait("Qualification", "Q17")] public void A_reference_from_another_state_generation_is_resolved_but_stale() { LuaRef reference = new(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs index afad9efb..e9b23074 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs @@ -119,6 +119,7 @@ public void References_are_invalidated_by_detach_and_reattach() } [Fact] + [Trait("Qualification", "Q17")] public void A_reference_from_the_pre_reset_state_never_releases_a_current_generation_slot() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs index fb808a04..58e044d9 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs @@ -22,6 +22,7 @@ public void Default_outcomes_expose_an_empty_failure_list() } [Fact] + [Trait("Qualification", "Q16")] public void Reject_existing_preflights_without_replacing_the_effective_global() { LuaTest.RequireNativeLua(); @@ -40,6 +41,7 @@ public void Reject_existing_preflights_without_replacing_the_effective_global() } [Fact] + [Trait("Qualification", "Q16")] public void Replace_existing_restores_the_prior_value_only_while_the_lease_still_owns_the_global() { LuaTest.RequireNativeLua(); @@ -63,6 +65,7 @@ public void Replace_existing_restores_the_prior_value_only_while_the_lease_still } [Fact] + [Trait("Qualification", "Q16")] public void Release_preserves_a_later_replacement_and_reports_it_without_writing() { LuaTest.RequireNativeLua(); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs index 4419e023..e7e584f6 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs @@ -39,6 +39,7 @@ public void Attach_publishes_the_binding_and_advances_the_attach_epoch_once() } [Fact] + [Trait("Qualification", "Q17")] public void BeginStateReset_advances_only_the_state_generation_while_the_host_remains_attached() { LuaTest.RequireNativeLua(); From 823aa04f85b88bb90de3395b5ef6da38ed208c4d Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:52:08 +0200 Subject: [PATCH 026/199] Add the Q01-Q48 qualification matrix The audit's 48 scenarios are exit criteria, not results, and nothing in the repository said which of them any evidence covers. Add docs/qualification/matrix.json: one row per Q01-Q48 plus 14 sub-rows (Q05.a, Q08.a, Q09.a-b, Q30.a-e, Q31.a, Q32.a-d), each with its audit wording, owner, required levels copied from analyses/20, findings, a scenario block (preconditions, operation, expected result, expected category) and one cell per level. Seeding is conservative. C0-C2 cells are Passed only where the traited tests of the previous commit ran green on this head (Q01-Q08, Q05.a, Q11, Q12, Q14-Q17, Q39), with RefusalVerified where the expected outcome is a refusal. Every C3/C4 cell is NotExecuted on the qualifiable profile, except the profile decisions: Q38 C3, Q39 C3, Q42 C3/C4 and the file-as-process, CEServer, x86-host and ARM sub-rows are NotApplicable with a justification. Client-owned rows (Q33, Q43-Q45) are NotApplicable here and point to the Client matrix. Parents equal their sub-rows' aggregate as the contract defines it. QualificationMatrixTests enforce the audit oracle, schema, structure, profiles, pass kinds, evidence kinds, aggregation, receipt resolution and freshness, Automated evidence resolved to traited methods of solution test modules, and trait/matrix parity in both directions. The README explains levels, statuses, the hash and freshness rules and how receipts are read; its matrix summary and the support profile's Not executed list are generated from the matrix and checked by tests. --- CheatEngine.SDK.slnx | 2 + docs/qualification/README.md | 168 +- docs/qualification/matrix.json | 2656 +++++++++++++++++ docs/qualification/support-profile.md | 53 + .../Qualification/QualificationMatrixTests.cs | 446 +++ .../Qualification/SupportProfileTests.cs | 15 + .../Validation/QualificationDocuments.cs | 31 + .../Validation/QualificationMarkdown.cs | 103 + .../Validation/QualificationRules.cs | 22 +- 9 files changed, 3488 insertions(+), 8 deletions(-) create mode 100644 docs/qualification/matrix.json create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationMatrixTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationMarkdown.cs diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 1e90e3ed..0ad33910 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -54,6 +54,8 @@ + + diff --git a/docs/qualification/README.md b/docs/qualification/README.md index f3f37965..93b482d2 100644 --- a/docs/qualification/README.md +++ b/docs/qualification/README.md @@ -2,6 +2,170 @@ > Recreated 2026-09 from the audit, not the historical documentations/ tree. -Support profiles, the Q01–Q48 qualification matrix and the receipts of executed C3/C4 runs. +This folder holds the evidence of what CheatEngine.SDK has actually been shown to do: the Cheat Engine host +[support profiles](support-profile.md), the Q01–Q48 [qualification matrix](matrix.json) of the 2026-09-22 audit +(`analyses/20`, identified by the SHA-256 `7179b0691d27cba0589b3f5fa00945ddbb7c04b362500df3de30726550f4ff6e` of its +`MANIFESTE.md`), the v0 [schemas](schemas/) of these documents and, once a host run has happened, the committed +receipts. The 48 scenarios are an acceptance plan: a row changes only with an executed, traited test (C0–C2) or a +committed receipt (C3/C4). There is no global score anywhere. -Status: placeholder — content arrives with S-QUAL (V1) +## Evidence levels + +| Level | Environment | What it can establish | +|-------|------------------------------------|-----------------------------------------------------------------------------------------------| +| C0 | Static contract: sources, analyzers | Theoretical offsets, signatures, references, absence of a forbidden dependency | +| C1 | Managed tests with doubles and seams | Composition, application rollback, typing, decisions, error mapping | +| C2 | Native fixture: controlled Lua and bridge | Lua C API contracts, allocation failures, stack and state restoration | +| C3 | The exact Cheat Engine binary with the plugin really loaded | Bootstrap, affinity, Cheat Engine objects, lifecycles, chosen runtime and effective APIs | +| C4 | Several components: two plugins and a controlled target | Coexistence, target switches, interference, side-by-side profiles | + +A C1 success never counts as a C3 success, and a NativeAOT publish that succeeds is never a Cheat Engine load or unload. +Only C3 and C4 cells can make a profile host-qualified, and only through committed receipts. + +## Reading a matrix cell + +- **status**: `NotExecuted`, `Passed`, `Failed` or `NotApplicable`. `NotApplicable` always carries a justification; a + `NotExecuted` cell never carries evidence or a date. +- **passKind** (only when `Passed`): `Functional` when the behaviour works, `RefusalVerified` when the expected outcome is + a refusal and the test proves the refusal. A refusal is never reported as a functional success. +- **evidenceKind**: `ToQualify` while not executed; `ObservedSource` for a C0–C2 cell backed by tests executed on the + source tree; `ObservedHost` for a C3/C4 cell backed by a receipt; `ProposedDecision` for a `NotApplicable` that + follows from a profile decision (see the [Checkpoint A decisions](support-profile.md#checkpoint-a-decisions)). + Declared values stay `DeclaredRepo` until something measures them. +- **evidence**: for C0–C2, `Automated` entries naming a `*.Tests` project of the solution, the file, `Class.Method` + and the trait `Qualification=Qxx` the method carries; for C3/C4, `Receipt` entries naming a committed receipt and its + hash. C1/C2 evidence is never accepted in a C3/C4 cell. +- **profileId**: every C3/C4 cell names the qualifiable profile. The documentary profile `ce-public-src-ec45d5f` can + never back a `Passed` or `Failed` cell. + +Each row also states its scenario: preconditions, operation, expected result and an **expectedCategory** — `Effect` +(the operation takes effect as requested), `Partial` (part of it takes effect and the partial effect is reported), +`Refused` (it is refused or fails cleanly with no effect left behind) or `Unknown` (the effect cannot be established +in advance and is reported as observed). `title` is an English summary; `titleFr` is the audit's wording, verbatim: +for a sub-row, the narrowest fragment the audit states (for example `analyses/12` line 39), otherwise the parent's +scenario. + +**Sub-rows** split a scenario into facets (`Q05.a`, `Q30.a`–`Q30.e`, `Q32.a`–`Q32.d`…). At every level where sub-rows +have cells, the parent equals their aggregate: `Failed` if any is `Failed`, `NotApplicable` if all are, `Passed` if all +are `Passed` or justified `NotApplicable`, otherwise `NotExecuted`. A sub-row gets a fixture-level cell when its owner +adds the test that evidences it. + +**Traits.** A test that evidences a scenario carries `[Trait("Qualification", "Qxx")]` on the method, and the matrix +cites it; every trait in the test sources is cited and every citation resolves to a traited method of a CI test module. +`dotnet test --project --filter-trait "Qualification=Q07"` runs exactly the evidence of one row. The mapping of +the existing tests was checked against the audit's success criterion of each row; tests whose assertions do not prove +the criterion stay untagged. + +## Matrix summary + +Generated from [`matrix.json`](matrix.json) and checked by +`QualificationMatrixTests.Matrix_summary_in_the_readme_equals_the_matrix`; "–" means the level has no cell. + + +| Row | Scenario | Owner | Required | C0 | C1 | C2 | C3 | C4 | +|-----|----------|-------|----------|----|----|----|----|----| +| Q01 | PluginVersion record and field offsets | SDK | C0, C1 | Passed | Passed | – | – | – | +| Q02 | Compact bootstrap record between guard bytes | SDK | C1, C3 | – | Passed | – | Not executed | – | +| Q03 | Reduced managed exports table or missing pointer | SDK | C1, C3 | – | Passed (refusal verified) | – | Not executed | – | +| Q04 | Observation of the second bootstrap integer | SDK | C3 | – | Passed | – | Not executed | – | +| Q05 | Name before enable, then enable, disable and enable | SDK | C3 | – | Passed | – | Not executed | – | +| Q05.a | Non-ASCII plugin name | SDK | C3 | – | Passed | – | Not executed | – | +| Q06 | Exception during construction or enable | SDK | C1, C3 | – | Passed | – | Not executed | – | +| Q07 | Re-entrant disable from a running callback | SDK | C1, C3 | – | Passed (refusal verified) | – | Not executed | – | +| Q08 | Partial cleanup, then diagnosis | SDK | C1, C3 | – | Passed | – | Not executed | – | +| Q08.a | Plugin disabled between an allocation and its publication | SDK | C3 | – | – | – | Not executed | – | +| Q09 | Two plugins sharing or not sharing the SDK assemblies | Both | C4 | – | – | – | – | Not executed | +| Q09.a | Coexistence with one shared SDK assemblies folder | Both | C4 | – | – | – | – | Not executed | +| Q09.b | Coexistence with separate plugin folders | Both | C4 | – | – | – | – | Not executed | +| Q10 | Two package versions in separate folders | Both | C4 | – | – | – | – | Not executed | +| Q11 | Missing Lua module or incomplete exports | SDK | C1, C2 | – | Passed (refusal verified) | Passed | – | – | +| Q12 | Allocation failure while growing the Lua stack | SDK | C2 | – | – | Passed | – | – | +| Q13 | Failure of string, table, userdata or reference creation | SDK | C2 | – | – | Not executed | – | – | +| Q14 | Managed callback that throws | SDK | C2, C3 | – | – | Passed | Not executed | – | +| Q15 | Callback kept after disable | SDK | C2, C3 | – | – | Passed | Not executed | – | +| Q16 | Global collision, replacement, then third-party replacement | Both | C2, C4 | – | – | Passed | – | Not executed | +| Q17 | Controlled Lua state replacement | SDK | C2, C3 | – | Passed | Passed | Not executed | – | +| Q18 | Use of a reference after an external reset | SDK | C3 | – | – | – | Not executed | – | +| Q19 | First calls from two workers | SDK | C3, C4 | – | – | – | Not executed | Not executed | +| Q20 | Bytes with NUL and multibyte strings | Both | C1, C2, C3 | – | Not executed | Not executed | Not executed | – | +| Q21 | Signed and unsigned 32-bit values and 64-bit boundaries | Both | C1, C2, C3 | – | Not executed | Not executed | Not executed | – | +| Q22 | nil, false, zero, zero results and Lua error | SDK | C2, C3 | – | – | Not executed | Not executed | – | +| Q23 | CE object, light userdata and foreign userdata | SDK | C2, C3 | – | – | Not executed | Not executed | – | +| Q24 | Bound method and 0-based indexers | SDK | C2, C3 | – | – | Not executed | Not executed | – | +| Q25 | Scanner created, then list creation or publication fails | SDK | C1, C3 | – | Not executed | – | Not executed | – | +| Q26 | FirstScan, NextScan, results and destruction | SDK | C3 | – | – | – | Not executed | – | +| Q27 | AOB scan: empty, error and malformed result | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q28 | AOB scan in a module with matches outside the module | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q29 | Result limit and cancellation during copy | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q30 | Reused PID and target switch | Both | C3, C4 | – | – | – | Not executed | Not executed | +| Q30.a | Targets A and B and a reused PID | Both | C3, C4 | – | – | – | Not executed | Not executed | +| Q30.b | Cleanup after a target switch | Both | C3, C4 | – | – | – | Not executed | Not executed | +| Q30.c | File opened as a process | Both | C1, C3 | – | Not executed | – | Not applicable | – | +| Q30.d | CEServer target | Both | C1, C3 | – | Not executed | – | Not applicable | – | +| Q30.e | Reuse of an old allocation address | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q31 | Overridden Cheat Engine pointer size | Both | C3 | – | – | – | Not executed | – | +| Q31.a | Configured pointer size smaller than the process width | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q32 | x86, x64, ARM or unknown host and target backends | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q32.a | x64 target | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q32.b | x86 target | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q32.c | x86 Cheat Engine host | Both | C3 | – | – | – | Not applicable | – | +| Q32.d | ARM or unknown backend | Both | C1, C3 | – | Not executed | – | Not applicable | – | +| Q33 | Memory batch that fails after several writes | Client | C1, C3 | – | Not applicable | – | Not applicable | – | +| Q34 | Record destroyed or table reloaded | Both | C3 | – | – | – | Not executed | – | +| Q35 | Script activation, then incomplete rollback | SDK | C3 | – | – | – | Not executed | – | +| Q36 | One-shot timer finished before Dispose | SDK | C3 | – | – | – | Not executed | – | +| Q37 | Hotkey callback during module shutdown | SDK | C3 | – | – | – | Not executed | – | +| Q38 | Debug or process-watcher event on a secondary thread | SDK | C3 | – | – | – | Not applicable | – | +| Q39 | Reduced or mutated classic exports table | SDK | C1, C3 | – | Passed (refusal verified) | – | Not applicable | – | +| Q40 | Clean installation from the package | Both | C3 | – | – | – | Not executed | – | +| Q41 | NativeAOT publish and export inspection | SDK | C0, C2 | Not executed | – | Not executed | – | – | +| Q42 | Removal of a NativeAOT plugin profile | SDK | C3, C4 | – | – | – | Not applicable | Not applicable | +| Q43 | Client cleanup with a faulty module | Client | C1, C3 | – | Not applicable | – | Not applicable | – | +| Q44 | A contract-only API is called | Client | C1, C3 | – | Not applicable | – | Not applicable | – | +| Q45 | Sensitive availability probe | Client | C1, C3 | – | Not applicable | – | Not applicable | – | +| Q46 | Logs containing user data or expressions | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q47 | Inherited property or method and public alias | SDK | C2, C3 | – | – | Not executed | Not executed | – | +| Q48 | SDK package updated without adapting the Client | Both | C1, C3 | – | Not executed | – | Not executed | – | + + +The rows still missing evidence are listed in the [support profile](support-profile.md#not-executed). + +## Receipts + +A C3 or C4 result is a receipt produced by the local runner +[`eng/qualification/Invoke-LocalQualification.ps1`](../../eng/qualification/README.md), never by CI. The runner copies +the profiled Cheat Engine installation into a sandbox, builds the plugins from the exact CI package into a clean folder, +drives Cheat Engine with an autorun Lua driver, restores the operator's registry state, and writes one receipt per +scenario: `receipts//.json` with its structured, redacted event log `.events.json` beside it. +The [local qualification protocol](local-protocol.md) describes the operator's side. A receipt names the tree, the pull +request and head commit, the package identities, the host, bridge, bundle and target hashes, the registry difference +(names only), the preconditions, operation, expected and observed results, the status and the timings. + +Receipts are committed together with the matrix cell they qualify. Never committed: Cheat Engine or target binaries, +authorization manifests, registry exports, raw debug output. + +## Hash and freshness rules + +- A `sha256` that names a committed JSON document (a receipt, an event log, the matrix, the support profile) is the + SHA-256 of its UTF-8 bytes after CRLF is normalized to LF. Working trees check text out with CRLF + (`.gitattributes`), while the committed blob and every clone's hash input are LF, so the rule gives the same value + everywhere. +- A receipt is valid for the tree (`git rev-parse HEAD^{tree}`) and the package it names; with squash merges the tree + hash, the pull request number and head commit, and the package SHA-256 are the durable identity. A C3/C4 `Passed` or + `Failed` cell names `treeHash` and `nupkgSha256`; a cited receipt from another tree or package requires the cell's + `transferJustification`. Without it, the result counts as `NotExecuted` for the new tree or package. + +## Schemas and tests + +| Document | Schema | +|-----------------------------------------|--------------------------------------------------------------------------------------------| +| [`support-profile.json`](support-profile.json) | [`support-profile.v0.schema.json`](schemas/support-profile.v0.schema.json) | +| [`matrix.json`](matrix.json) | [`qualification-matrix.v0.schema.json`](schemas/qualification-matrix.v0.schema.json) | +| `receipts//.json` | [`qualification-receipt.v0.schema.json`](schemas/qualification-receipt.v0.schema.json) | +| `receipts//.events.json` | [`qualification-events.v0.schema.json`](schemas/qualification-events.v0.schema.json) | + +The schemas are JSON Schema draft 2020-12 with closed objects. The C# tests in +`tests/CheatEngine.SDK.Repository.Tests/Qualification` validate every document with a validator for exactly the keywords +the schemas use, plus the rules a schema cannot express (`QualificationSchemaTests`, `SupportProfileTests`, +`QualificationMatrixTests`, `QualificationReceiptTests`). The JSON documents are kept in a canonical form (two-space +indentation, LF, readable non-ASCII, rows sorted by id) so that concurrent edits of different rows merge line by line. diff --git a/docs/qualification/matrix.json b/docs/qualification/matrix.json new file mode 100644 index 00000000..21d04b3e --- /dev/null +++ b/docs/qualification/matrix.json @@ -0,0 +1,2656 @@ +{ + "schema": "cheatengine-qualification-matrix/v0", + "repository": "CheatEngineNet/CheatEngine.SDK", + "audit": { + "manifestSha256": "7179b0691d27cba0589b3f5fa00945ddbb7c04b362500df3de30726550f4ff6e" + }, + "profiles": [ + "ce-public-src-ec45d5f", + "ce-7.7.0.10621-x64-managed-hostfxr" + ], + "rows": [ + { + "id": "Q01", + "parent": null, + "title": "PluginVersion record and field offsets", + "titleFr": "Record `PluginVersion` et offsets", + "owner": "SDK", + "requiredLevels": [ + "C0", + "C1" + ], + "blocks": "ABI", + "audit": { + "ref": "analyses/20 Q01", + "findings": [] + }, + "scenario": { + "preconditions": [ + "x64 process; the SDK is x64-only." + ], + "operation": "Compare the managed PluginVersion record with the header-derived x64 layout and call GetVersion through the host record.", + "expected": "Size 16 bytes, Version at offset 0 and PluginName at offset 8 with the padding untouched; GetVersion writes version 6 and the bootstrap name pointer.", + "expectedCategory": "Effect" + }, + "levels": { + "C0": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs", + "test": "NativeAbiFixtureContractTests.Header_derived_classic_records_have_the_fixture_x64_sizes", + "trait": "Qualification=Q01" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureContractTests.cs", + "test": "NativeAbiFixtureContractTests.Header_derived_classic_records_have_the_fixture_x64_alignments", + "trait": "Qualification=Q01" + } + ], + "date": "2026-09-23" + }, + "C1": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs", + "test": "PluginVersionTests.Size_on_64_bit_is_16_bytes", + "trait": "Qualification=Q01" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs", + "test": "PluginVersionTests.Field_offsets_on_64_bit_match_the_c_structure", + "trait": "Qualification=Q01" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Native/PluginVersionTests.cs", + "test": "PluginVersionTests.Write_through_a_pointer_on_64_bit_leaves_the_padding_alone_and_places_the_name_at_8", + "trait": "Qualification=Q01" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/GetVersionTests.cs", + "test": "GetVersionTests.Fills_version_6_and_the_bootstrap_name_pointer", + "trait": "Qualification=Q01" + } + ], + "date": "2026-09-23" + } + } + }, + { + "id": "Q02", + "parent": null, + "title": "Compact bootstrap record between guard bytes", + "titleFr": "Record bootstrap compact avec sentinelles autour", + "owner": "SDK", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Bootstrap", + "audit": { + "ref": "analyses/20 Q02", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "The host hands a 36-byte buffer for the packed PluginInitRecord." + ], + "operation": "Run the managed bootstrap on a buffer surrounded by guard bytes, at an aligned and at an odd address (C3: tail canary read around the host buffer).", + "expected": "Exactly the 36 record bytes change; every guard byte keeps its value.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs", + "test": "PluginInitRecordTests.Size_on_64_bit_is_36_bytes_packed", + "trait": "Qualification=Q02" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs", + "test": "PluginInitRecordTests.Field_offsets_on_64_bit_match_the_host_record", + "trait": "Qualification=Q02" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Managed/PluginInitRecordTests.cs", + "test": "PluginInitRecordTests.Write_through_a_pointer_on_64_bit_touches_exactly_36_bytes", + "trait": "Qualification=Q02" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs", + "test": "InitializeManagedTests.Writes_exactly_the_36_byte_record_and_nothing_past_it", + "trait": "Qualification=Q02" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs", + "test": "InitializeManagedTests.Writes_the_record_at_an_odd_address_without_touching_the_guard", + "trait": "Qualification=Q02" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q03", + "parent": null, + "title": "Reduced managed exports table or missing pointer", + "titleFr": "Table managée réduite ou pointeur manquant", + "owner": "SDK", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Bootstrap", + "audit": { + "ref": "analyses/20 Q03", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "The bootstrap has run." + ], + "operation": "Enable with a null exports record, an undersized record or a record without GetLuaState (C3: record the exports size the host reports).", + "expected": "The enable is refused and logged before any read past the reported size and before any Lua or plugin call.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "RefusalVerified", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.A_null_exports_record_fails_without_touching_Lua", + "trait": "Qualification=Q03" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.An_undersized_exports_record_fails_cleanly", + "trait": "Qualification=Q03" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.A_record_without_GetLuaState_fails", + "trait": "Qualification=Q03" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q04", + "parent": null, + "title": "Observation of the second bootstrap integer", + "titleFr": "Observation du deuxième entier du bootstrap", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Contrat CE 7.7", + "audit": { + "ref": "analyses/20 Q04", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "LiveProbe built from the exact CI package and loaded on the qualifiable profile." + ], + "operation": "Read the raw second CEPluginInitialize argument from ce77_live_probe_status_json().", + "expected": "The raw integer is recorded as observed, with no size, length or version meaning attached.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs", + "test": "InitializeManagedTests.An_opaque_bootstrap_argument_is_recorded_without_changing_the_record_write", + "trait": "Qualification=Q04" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q05", + "parent": null, + "subRows": [ + "Q05.a" + ], + "title": "Name before enable, then enable, disable and enable", + "titleFr": "Nom avant enable, puis enable/disable/enable", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Cycle de vie", + "audit": { + "ref": "analyses/20 Q05", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "LiveProbe built from the exact CI package and loaded on the qualifiable profile." + ], + "operation": "Read the plugin name before the first enable, then enable, disable and enable again, recording plugin id and epoch each time.", + "expected": "The name is stable, one plugin instance is reused and every enable gets a new epoch.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs", + "test": "InitializeManagedTests.Second_call_is_idempotent_and_writes_the_same_bytes_including_the_name_pointer", + "trait": "Qualification=Q05" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs", + "test": "DisablePluginTests.Enable_disable_enable_reuses_the_instance_and_attaches_with_a_new_epoch", + "trait": "Qualification=Q05" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q05.a", + "parent": "Q05", + "title": "Non-ASCII plugin name", + "titleFr": "nom non ASCII", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Cycle de vie", + "audit": { + "ref": "analyses/20 Q05 ; analyses/02 l.41", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "LiveProbe built with -p:LiveProbeNonAsciiName=true." + ], + "operation": "Load the non-ASCII variant and record the name bytes the SDK wrote and the name Cheat Engine shows (operator observation).", + "expected": "The decoding Cheat Engine applies (ANSI code page or UTF-8) is recorded; the name never corrupts the record or the plugin list.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Bootstrap/InitializeManagedTests.cs", + "test": "InitializeManagedTests.Non_ASCII_name_is_converted_to_the_process_ANSI_code_page", + "trait": "Qualification=Q05.a" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q06", + "parent": null, + "title": "Exception during construction or enable", + "titleFr": "Exception pendant construction / enable", + "owner": "SDK", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Fiabilité hôte", + "audit": { + "ref": "analyses/20 Q06", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "LiveProbe loaded; C3 uses liveprobe.fault.json with throwIn FactoryCreate or OnEnable." + ], + "operation": "Make the plugin constructor or OnEnable throw.", + "expected": "Cheat Engine is told the enable failed, no exception crosses an ABI callback, the runtime is detached, the failure is logged and a later enable can succeed.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.OnEnable_throwing_makes_the_enable_fail_and_detaches_the_runtime", + "trait": "Qualification=Q06" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.A_throwing_constructor_fails_the_enable_and_is_retried_on_the_next_enable", + "trait": "Qualification=Q06" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q07", + "parent": null, + "title": "Re-entrant disable from a running callback", + "titleFr": "Disable réentrant depuis callback en cours", + "owner": "SDK", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Cycle de vie", + "audit": { + "ref": "analyses/20 Q07", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "The plugin is enabled; C3 runs ce77_live_probe_pump_messages while the operator unticks the plugin." + ], + "operation": "Request a disable while a lifecycle transition, an admitted Lua operation or dispatched main-thread work of the same plugin is still running.", + "expected": "The nested disable is refused with a defined result and a log entry, without waiting on itself; the outer transition stands.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "RefusalVerified", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs", + "test": "ReentrancyTests.A_concurrent_disable_during_OnEnable_fails_immediately_and_the_outer_enable_decides_the_state", + "trait": "Qualification=Q07" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs", + "test": "ReentrancyTests.Disable_nested_in_OnEnable_is_refused_and_the_enable_stands", + "trait": "Qualification=Q07" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/ReentrancyTests.cs", + "test": "ReentrancyTests.Disable_nested_in_OnDisable_is_refused_and_OnDisable_runs_once", + "trait": "Qualification=Q07" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs", + "test": "DisablePluginTests.Disable_from_an_admitted_Lua_operation_is_refused_without_changing_the_lifecycle", + "trait": "Qualification=Q07" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs", + "test": "DisablePluginTests.Disable_from_executing_dispatched_work_is_refused_without_waiting_for_that_work", + "trait": "Qualification=Q07" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q08", + "parent": null, + "subRows": [ + "Q08.a" + ], + "title": "Partial cleanup, then diagnosis", + "titleFr": "Nettoyage partiel puis diagnostic", + "owner": "SDK", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Fiabilité hôte", + "audit": { + "ref": "analyses/20 Q08", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "The plugin is enabled; C3 uses liveprobe.fault.json with throwIn OnDisable." + ], + "operation": "Make one cleanup step fail during disable or failed-enable cleanup, then retry.", + "expected": "The failure is logged, the lifecycle never reports a cleanly disabled plugin while cleanup is incomplete, and a retry completes it.", + "expectedCategory": "Partial" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.OnEnable_failure_with_detach_failure_keeps_incomplete_cleanup_retryable", + "trait": "Qualification=Q08" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.A_failed_cleanup_retry_rejects_nested_disable_until_the_retry_unwinds", + "trait": "Qualification=Q08" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs", + "test": "LuaCallbackTests.Detach_cleanup_failure_keeps_remaining_callbacks_and_allows_a_retry", + "trait": "Qualification=Q08" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q08.a", + "parent": "Q08", + "title": "Plugin disabled between an allocation and its publication", + "titleFr": "désactivation entre allocation et publication", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Fiabilité hôte", + "audit": { + "ref": "analyses/20 Q08 ; analyses/12 l.39", + "findings": [ + "F01" + ] + }, + "scenario": { + "preconditions": [ + "A resource factory allocates a host resource; the plugin is disabled before the owner is published." + ], + "operation": "Disable the plugin between the allocation and the publication of its owner.", + "expected": "The operation is refused, or reported with an unknown effect, and exactly one compensation is reported.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q09", + "parent": null, + "subRows": [ + "Q09.a", + "Q09.b" + ], + "title": "Two plugins sharing or not sharing the SDK assemblies", + "titleFr": "Deux plugins partageant ou non les assemblies SDK", + "owner": "Both", + "requiredLevels": [ + "C4" + ], + "blocks": "Communauté", + "audit": { + "ref": "analyses/20 Q09", + "findings": [ + "F03" + ] + }, + "scenario": { + "preconditions": [ + "Coexistence plugins A and B built from the exact CI package." + ], + "operation": "Load A and B in one Cheat Engine session, record the plugin and Hosting assembly identities, then disable A while B keeps working and re-enable A.", + "expected": "Assembly MVIDs, load contexts and PluginHost static identity are measured; enable and removal of A and B are independent as the profile announces.", + "expectedCategory": "Effect" + }, + "levels": { + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q09.a", + "parent": "Q09", + "title": "Coexistence with one shared SDK assemblies folder", + "titleFr": "Deux plugins utilisant les mêmes bibliothèques", + "owner": "Both", + "requiredLevels": [ + "C4" + ], + "blocks": "Communauté", + "audit": { + "ref": "analyses/20 Q09 ; analyses/04 l.38", + "findings": [ + "F03" + ] + }, + "scenario": { + "preconditions": [ + "A and B copied into one folder; the runner refuses a same-named file with different bytes." + ], + "operation": "Run the Q09 sequence with both plugins loaded from the shared folder.", + "expected": "The measured identities show whether A and B share one Hosting instance; each plugin keeps working while the other is removed.", + "expectedCategory": "Effect" + }, + "levels": { + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q09.b", + "parent": "Q09", + "title": "Coexistence with separate plugin folders", + "titleFr": "Deux plugins partageant ou non les assemblies SDK", + "owner": "Both", + "requiredLevels": [ + "C4" + ], + "blocks": "Communauté", + "audit": { + "ref": "analyses/20 Q09", + "findings": [ + "F03" + ] + }, + "scenario": { + "preconditions": [ + "A and B in separate folders, each with its own SDK assemblies." + ], + "operation": "Run the Q09 sequence with each plugin loaded from its own folder.", + "expected": "The measured identities show which SDK assemblies each plugin uses; each plugin keeps working while the other is removed.", + "expectedCategory": "Effect" + }, + "levels": { + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q10", + "parent": null, + "title": "Two package versions in separate folders", + "titleFr": "Deux versions de paquet dans répertoires séparés", + "owner": "Both", + "requiredLevels": [ + "C4" + ], + "blocks": "Distribution", + "audit": { + "ref": "analyses/20 Q10", + "findings": [ + "F03" + ] + }, + "scenario": { + "preconditions": [ + "A plugin on one SDK package version and a plugin on another, each in its own folder." + ], + "operation": "Load both in one Cheat Engine session and record the assemblies each one actually uses.", + "expected": "The assemblies really used and the behaviour of both plugins are recorded.", + "expectedCategory": "Effect" + }, + "levels": { + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q11", + "parent": null, + "title": "Missing Lua module or incomplete exports", + "titleFr": "Module Lua absent ou exports incomplets", + "owner": "SDK", + "requiredLevels": [ + "C1", + "C2" + ], + "blocks": "Lua", + "audit": { + "ref": "analyses/20 Q11", + "findings": [] + }, + "scenario": { + "preconditions": [ + "No lua53-64.dll in the process, or a module that lacks exports the SDK binds." + ], + "operation": "Enable the plugin.", + "expected": "The enable is refused before any plugin code and no second Lua runtime is loaded; when the host module is present it is found without loading another.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "RefusalVerified", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.Without_a_Lua_module_in_the_process_the_enable_fails_before_any_plugin_code", + "trait": "Qualification=Q11" + } + ], + "date": "2026-09-23" + }, + "C2": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/EnablePluginTests.cs", + "test": "EnablePluginTests.The_production_module_lookup_finds_the_fixture_when_it_is_Cheat_Engines_DLL", + "trait": "Qualification=Q11" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Interop.Tests/CheatEngine.SDK.Lua.Interop.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Interop.Tests/Loading/LuaModuleTests.cs", + "test": "LuaModuleTests.TryGetLoaded_finds_the_lua_module_the_fixture_loaded_without_loading_another", + "trait": "Qualification=Q11" + } + ], + "date": "2026-09-23" + } + } + }, + { + "id": "Q12", + "parent": null, + "title": "Allocation failure while growing the Lua stack", + "titleFr": "Échec d’allocation pendant croissance de pile", + "owner": "SDK", + "requiredLevels": [ + "C2" + ], + "blocks": "Pont", + "audit": { + "ref": "analyses/20 Q12", + "findings": [] + }, + "scenario": { + "preconditions": [ + "Native Lua fixture with an allocator that rejects the growth." + ], + "operation": "Grow the stack or push a generated closure while the allocation fails.", + "expected": "An error status is returned and the stack is restored.", + "expectedCategory": "Refused" + }, + "levels": { + "C2": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Protected/CheckStackFailureProcessTests.cs", + "test": "CheckStackFailureProcessTests.Direct_checkstack_growth_with_a_rejecting_allocator_returns_zero_and_recovers", + "trait": "Qualification=Q12" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Protected/NativeFailureProcessTests.cs", + "test": "NativeFailureProcessTests.Generated_function_PushClosure_failure_returns_status_and_restores_stack", + "trait": "Qualification=Q12" + } + ], + "date": "2026-09-23" + } + } + }, + { + "id": "Q13", + "parent": null, + "title": "Failure of string, table, userdata or reference creation", + "titleFr": "Échec de string/table/userdata/ref", + "owner": "SDK", + "requiredLevels": [ + "C2" + ], + "blocks": "Pont", + "audit": { + "ref": "analyses/20 Q13", + "findings": [] + }, + "scenario": { + "preconditions": [ + "Native Lua fixture with an allocator that fails at each creation point." + ], + "operation": "Create a string, table, userdata or registry reference while the allocation fails.", + "expected": "No Lua error crosses a managed frame; the failure is reported as a status.", + "expectedCategory": "Refused" + }, + "levels": { + "C2": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + } + } + }, + { + "id": "Q14", + "parent": null, + "title": "Managed callback that throws", + "titleFr": "Callback managé qui lève une exception", + "owner": "SDK", + "requiredLevels": [ + "C2", + "C3" + ], + "blocks": "Callbacks", + "audit": { + "ref": "analyses/20 Q14", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A managed Lua callback is registered; C3 calls ce77_live_probe_throw_managed_exception under pcall." + ], + "operation": "Throw a managed exception inside the callback thunk.", + "expected": "The caller receives a catchable Lua error after the thunk returns, and the next call on the same state works.", + "expectedCategory": "Refused" + }, + "levels": { + "C2": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs", + "test": "LuaCallbackTests.A_failure_reported_by_the_thunk_is_a_catchable_lua_error_with_the_message", + "trait": "Qualification=Q14" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs", + "test": "LuaCallbackTests.A_managed_exception_inside_a_thunk_never_escapes_and_becomes_a_lua_error", + "trait": "Qualification=Q14" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q15", + "parent": null, + "title": "Callback kept after disable", + "titleFr": "Callback conservé après disable", + "owner": "SDK", + "requiredLevels": [ + "C2", + "C3" + ], + "blocks": "Callbacks", + "audit": { + "ref": "analyses/20 Q15", + "findings": [ + "F12" + ] + }, + "scenario": { + "preconditions": [ + "A script kept a reference to a plugin callback; C3 uses ce77_live_probe_prepare_callback_shutdown." + ], + "operation": "Disable the plugin, then call the kept callback under pcall.", + "expected": "The call is neutralized with an error and no managed state is freed behind a live closure.", + "expectedCategory": "Refused" + }, + "levels": { + "C2": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs", + "test": "LuaCallbackTests.Releasing_a_callback_neutralizes_the_closure_a_script_kept", + "trait": "Qualification=Q15" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs", + "test": "LuaCallbackTests.Detach_neutralizes_every_callback_the_plugin_forgot", + "trait": "Qualification=Q15" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj", + "file": "tests/CheatEngine.SDK.Hosting.Tests/Lifecycle/DisablePluginTests.cs", + "test": "DisablePluginTests.Disable_neutralizes_the_callbacks_the_plugin_forgot", + "trait": "Qualification=Q15" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q16", + "parent": null, + "title": "Global collision, replacement, then third-party replacement", + "titleFr": "Collision de global, remplacement puis remplacement tiers", + "owner": "Both", + "requiredLevels": [ + "C2", + "C4" + ], + "blocks": "Registrations", + "audit": { + "ref": "analyses/20 Q16", + "findings": [ + "F12" + ] + }, + "scenario": { + "preconditions": [ + "A Lua global of the same name already exists, or is replaced by a third party after registration." + ], + "operation": "Register the global with the default and the replacing policy, then release the registration.", + "expected": "A collision is refused by default, a replaced value is restored only while the registration still owns the global, and a third-party replacement is never overwritten.", + "expectedCategory": "Refused" + }, + "levels": { + "C2": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs", + "test": "LuaRegistrationSetTests.Reject_existing_preflights_without_replacing_the_effective_global", + "trait": "Qualification=Q16" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs", + "test": "LuaRegistrationSetTests.Replace_existing_restores_the_prior_value_only_while_the_lease_still_owns_the_global", + "trait": "Qualification=Q16" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Registration/LuaRegistrationSetTests.cs", + "test": "LuaRegistrationSetTests.Release_preserves_a_later_replacement_and_reports_it_without_writing", + "trait": "Qualification=Q16" + } + ], + "date": "2026-09-23" + }, + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q17", + "parent": null, + "title": "Controlled Lua state replacement", + "titleFr": "Remplacement contrôlé d’état Lua", + "owner": "SDK", + "requiredLevels": [ + "C2", + "C3" + ], + "blocks": "Reset", + "audit": { + "ref": "analyses/20 Q17", + "findings": [] + }, + "scenario": { + "preconditions": [ + "References and callbacks exist before the SDK-controlled state reset." + ], + "operation": "Run the SDK state-reset transition, then use the old references and callbacks.", + "expected": "Attach epoch and state generation stay coherent, old references are refused, and old callbacks are neutralized before the state is replaced.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs", + "test": "LuaRefEpochTests.A_reference_from_another_epoch_is_resolved_but_stale", + "trait": "Qualification=Q17" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/References/LuaRefEpochTests.cs", + "test": "LuaRefEpochTests.A_reference_from_another_state_generation_is_resolved_but_stale", + "trait": "Qualification=Q17" + } + ], + "date": "2026-09-23" + }, + "C2": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/References/LuaRefTests.cs", + "test": "LuaRefTests.A_reference_from_the_pre_reset_state_never_releases_a_current_generation_slot", + "trait": "Qualification=Q17" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Runtime/LuaRuntimeTests.cs", + "test": "LuaRuntimeTests.BeginStateReset_advances_only_the_state_generation_while_the_host_remains_attached", + "trait": "Qualification=Q17" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj", + "file": "tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs", + "test": "LuaCallbackTests.BeginStateReset_neutralizes_every_callback_before_the_state_is_replaced", + "trait": "Qualification=Q17" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q18", + "parent": null, + "title": "Use of a reference after an external reset", + "titleFr": "Tentative d’usage d’une ref après reset externe", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Reset", + "audit": { + "ref": "analyses/20 Q18", + "findings": [] + }, + "scenario": { + "preconditions": [ + "LiveProbe enabled; the operator calls resetLuaState() from the Lua Engine between the two snapshots." + ], + "operation": "Capture a reference before an external reset the SDK was not told about, then try to use it.", + "expected": "The outcome is recorded and reported as an unsupported profile; no safety is claimed that the run does not show.", + "expectedCategory": "Unknown" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q19", + "parent": null, + "title": "First calls from two workers", + "titleFr": "Premiers appels depuis deux workers", + "owner": "SDK", + "requiredLevels": [ + "C3", + "C4" + ], + "blocks": "Threading", + "audit": { + "ref": "analyses/20 Q19", + "findings": [ + "F04" + ] + }, + "scenario": { + "preconditions": [ + "Two worker threads, and at C4 two SDK copies, make their first Lua-bound calls." + ], + "operation": "Start the first calls concurrently from the workers.", + "expected": "Either the serialization policy is demonstrated or the calls are not admitted; the conservative default admits them only on the main thread.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + }, + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q20", + "parent": null, + "title": "Bytes with NUL and multibyte strings", + "titleFr": "Bytes contenant NUL et chaînes multioctets", + "owner": "Both", + "requiredLevels": [ + "C1", + "C2", + "C3" + ], + "blocks": "Marshalling", + "audit": { + "ref": "analyses/20 Q20", + "findings": [] + }, + "scenario": { + "preconditions": [ + "Strings with embedded NUL bytes, multibyte UTF-8 and invalid sequences." + ], + "operation": "Pass and read them through the SDK string and byte bindings.", + "expected": "The exact binary length is kept and every text conversion is explicit.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C2": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q21", + "parent": null, + "title": "Signed and unsigned 32-bit values and 64-bit boundaries", + "titleFr": "32 bits signés / non signés et 64 bits limites", + "owner": "Both", + "requiredLevels": [ + "C1", + "C2", + "C3" + ], + "blocks": "Mémoire", + "audit": { + "ref": "analyses/20 Q21", + "findings": [] + }, + "scenario": { + "preconditions": [ + "Values at the 32-bit and 64-bit signed and unsigned boundaries." + ], + "operation": "Write and read them through the memory and integer bindings.", + "expected": "No value passes through a double that loses bits.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C2": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q22", + "parent": null, + "title": "nil, false, zero, zero results and Lua error", + "titleFr": "nil, faux, zéro, zéro résultat et erreur Lua", + "owner": "SDK", + "requiredLevels": [ + "C2", + "C3" + ], + "blocks": "Erreurs", + "audit": { + "ref": "analyses/20 Q22", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A binding whose Lua target can return each of these shapes." + ], + "operation": "Call it for each shape.", + "expected": "The results stay distinguishable as the member defines them.", + "expectedCategory": "Effect" + }, + "levels": { + "C2": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q23", + "parent": null, + "title": "CE object, light userdata and foreign userdata", + "titleFr": "Objet CE, light userdata et userdata tiers", + "owner": "SDK", + "requiredLevels": [ + "C2", + "C3" + ], + "blocks": "Objets", + "audit": { + "ref": "analyses/20 Q23", + "findings": [ + "F11" + ] + }, + "scenario": { + "preconditions": [ + "A Cheat Engine object, a light userdata and a foreign full userdata such as an io.open handle." + ], + "operation": "Pass each one where the SDK expects a Cheat Engine object.", + "expected": "The trust domain is explicit: only the qualified provider path is accepted and incompatible forms are refused.", + "expectedCategory": "Refused" + }, + "levels": { + "C2": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q24", + "parent": null, + "title": "Bound method and 0-based indexers", + "titleFr": "Méthode liée et indexeurs 0-based", + "owner": "SDK", + "requiredLevels": [ + "C2", + "C3" + ], + "blocks": "Objets", + "audit": { + "ref": "analyses/20 Q24", + "findings": [ + "F11" + ] + }, + "scenario": { + "preconditions": [ + "A Cheat Engine class with methods and 0-based indexers." + ], + "operation": "Call a bound method and read indexed members.", + "expected": "No self argument is added by mistake and the 0-based indices are kept.", + "expectedCategory": "Effect" + }, + "levels": { + "C2": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q25", + "parent": null, + "title": "Scanner created, then list creation or publication fails", + "titleFr": "Création scanner puis échec de liste / publication", + "owner": "SDK", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Scans", + "audit": { + "ref": "analyses/20 Q25", + "findings": [ + "F05" + ] + }, + "scenario": { + "preconditions": [ + "A scanner is created and a later child creation or managed publication fails." + ], + "operation": "Inject the failure after the scanner exists.", + "expected": "Children are rolled back before the parent and a cleanup failure stays visible.", + "expectedCategory": "Partial" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q26", + "parent": null, + "title": "FirstScan, NextScan, results and destruction", + "titleFr": "FirstScan / NextScan / résultats / destruction", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Scans", + "audit": { + "ref": "analyses/20 Q26", + "findings": [ + "F05" + ] + }, + "scenario": { + "preconditions": [ + "QualificationTarget running with an Int32/Int64 value cell that changes on the step command." + ], + "operation": "Run a first scan, change the value, run a next scan, read the results and destroy the scan objects.", + "expected": "The sequence is valid and every object has a single owner that destroys it once.", + "expectedCategory": "Effect" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q27", + "parent": null, + "title": "AOB scan: empty, error and malformed result", + "titleFr": "AOB : vide, erreur, résultat mal formé", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "AOB", + "audit": { + "ref": "analyses/20 Q27", + "findings": [ + "F06" + ] + }, + "scenario": { + "preconditions": [ + "A pattern absent from the target (zero matches), a failing call and a malformed result." + ], + "operation": "Run the AOB scan for each case.", + "expected": "Absence and error are never merged into one outcome; on this host zero matches arrive as no value, which the SDK reports as the factual NoResult category.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q28", + "parent": null, + "title": "AOB scan in a module with matches outside the module", + "titleFr": "AOB dans module avec résultats hors module", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "AOB", + "audit": { + "ref": "analyses/20 Q28", + "findings": [ + "F07" + ] + }, + "scenario": { + "preconditions": [ + "QualificationTarget with a module-resident marker and heap copies of it." + ], + "operation": "Scan for the marker restricted to the module.", + "expected": "The filter is exact, and a native cost is never presented as bounded when the results are post-filtered.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q29", + "parent": null, + "title": "Result limit and cancellation during copy", + "titleFr": "Limite de résultats et annulation pendant copie", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "AOB", + "audit": { + "ref": "analyses/20 Q29", + "findings": [ + "F07" + ] + }, + "scenario": { + "preconditions": [ + "QualificationTarget region with many repetitions of a pattern." + ], + "operation": "Scan with a result limit, and cancel while the results are copied.", + "expected": "Truncation and cancellation are explicit and the result list is released.", + "expectedCategory": "Partial" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q30", + "parent": null, + "subRows": [ + "Q30.a", + "Q30.b", + "Q30.c", + "Q30.d", + "Q30.e" + ], + "title": "Reused PID and target switch", + "titleFr": "Même PID réutilisé / changement de cible", + "owner": "Both", + "requiredLevels": [ + "C3", + "C4" + ], + "blocks": "Cible", + "audit": { + "ref": "analyses/20 Q30", + "findings": [] + }, + "scenario": { + "preconditions": [ + "Owners created against target A; the operator switches the target." + ], + "operation": "Switch between targets and reuse PIDs before releasing the owners.", + "expected": "An old owner is refused or released through its qualified origin context, never redirected to another target.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + }, + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q30.a", + "parent": "Q30", + "title": "Targets A and B and a reused PID", + "titleFr": "cible A/B et PID réutilisé", + "owner": "Both", + "requiredLevels": [ + "C3", + "C4" + ], + "blocks": "Cible", + "audit": { + "ref": "analyses/20 Q30 ; analyses/12 l.39", + "findings": [] + }, + "scenario": { + "preconditions": [ + "Two QualificationTarget instances; the receipt states how PID reuse was obtained and never claims one that did not happen." + ], + "operation": "Switch A to B to A, or restart a target to reuse its PID, with owners still alive.", + "expected": "Owners of the previous target are refused, never applied to the new process.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + }, + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q30.b", + "parent": "Q30", + "title": "Cleanup after a target switch", + "titleFr": "cleanup après changement de cible", + "owner": "Both", + "requiredLevels": [ + "C3", + "C4" + ], + "blocks": "Cible", + "audit": { + "ref": "analyses/20 Q30 ; analyses/12 l.39", + "findings": [] + }, + "scenario": { + "preconditions": [ + "An allocation made in target A; the operator selects target B." + ], + "operation": "Release the allocation after the switch.", + "expected": "Cleanup never calls an ambient deallocation in B; it is refused and the residue is reported.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + }, + "C4": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q30.c", + "parent": "Q30", + "title": "File opened as a process", + "titleFr": "fichier comme processus", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Cible", + "audit": { + "ref": "analyses/20 Q30 ; analyses/12 l.39", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A file opened as a process instead of a live target." + ], + "operation": "Use the SDK process-bound operations on it.", + "expected": "Refused at C1; NotApplicable at C3 because the lab qualifies local processes only.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "The qualified backend of the profile is LocalProcess only; no file opened as a process is an authorized lab target. The C1 refusal is the evidence for this sub-row." + } + } + }, + { + "id": "Q30.d", + "parent": "Q30", + "title": "CEServer target", + "titleFr": "CEServer", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Cible", + "audit": { + "ref": "analyses/20 Q30 ; analyses/12 l.39", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A target reached through CEServer." + ], + "operation": "Use the SDK process-bound operations on it.", + "expected": "Refused at C1; NotApplicable at C3 because no CEServer exists in the lab.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "No CEServer exists in the lab and the qualified backend is LocalProcess only. The C1 refusal is the evidence for this sub-row." + } + } + }, + { + "id": "Q30.e", + "parent": "Q30", + "title": "Reuse of an old allocation address", + "titleFr": "réutilisation d’une ancienne adresse d’allocation", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Cible", + "audit": { + "ref": "analyses/20 Q30 ; analyses/12 l.39", + "findings": [] + }, + "scenario": { + "preconditions": [ + "An allocation was released and its address can be handed out again." + ], + "operation": "Use an owner of the old allocation after its address was reused.", + "expected": "The stale owner is refused and never releases the new allocation.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q31", + "parent": null, + "subRows": [ + "Q31.a" + ], + "title": "Overridden Cheat Engine pointer size", + "titleFr": "Pointer size CE surchargée", + "owner": "Both", + "requiredLevels": [ + "C3" + ], + "blocks": "Runtime", + "audit": { + "ref": "analyses/20 Q31", + "findings": [ + "F08" + ] + }, + "scenario": { + "preconditions": [ + "setPointerSize is called after the last target selection, because openProcess resets it; the value is verified right before the snapshot and restored after it." + ], + "operation": "Observe the configured pointer size next to the process architecture on a 64-bit target.", + "expected": "The configured size is reported as a fact distinct from the process width.", + "expectedCategory": "Effect" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q31.a", + "parent": "Q31", + "title": "Configured pointer size smaller than the process width", + "titleFr": "pointeur configuré plus petit que la largeur du processus", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Runtime", + "audit": { + "ref": "analyses/20 Q31 ; analyses/12 l.39", + "findings": [ + "F08" + ] + }, + "scenario": { + "preconditions": [ + "getPointerSize() returns 4 on a 64-bit target." + ], + "operation": "Use the pointer codecs while the configured size is smaller than the process width.", + "expected": "The two observations stay distinct and pointer codecs are refused or flagged.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q32", + "parent": null, + "subRows": [ + "Q32.a", + "Q32.b", + "Q32.c", + "Q32.d" + ], + "title": "x86, x64, ARM or unknown host and target backends", + "titleFr": "Hôte/cible x86, x64, ARM ou backend inconnu", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Runtime", + "audit": { + "ref": "analyses/20 Q32", + "findings": [ + "F08" + ] + }, + "scenario": { + "preconditions": [ + "A target is selected; with no target selected the SDK reports that first." + ], + "operation": "Observe the instruction set and width for each host and target combination.", + "expected": "An unknown value is reported rather than an instruction set deduced from the 64-bit flag alone; on x64 both the x86-family and 64-bit flags are set.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q32.a", + "parent": "Q32", + "title": "x64 target", + "titleFr": "Hôte/cible x86, x64, ARM ou backend inconnu", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Runtime", + "audit": { + "ref": "analyses/20 Q32", + "findings": [ + "F08" + ] + }, + "scenario": { + "preconditions": [ + "QualificationTarget x64 selected on the x64 host." + ], + "operation": "Observe the target profile and width.", + "expected": "X64 is reported: x86 family and 64-bit.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q32.b", + "parent": "Q32", + "title": "x86 target", + "titleFr": "Hôte/cible x86, x64, ARM ou backend inconnu", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Runtime", + "audit": { + "ref": "analyses/20 Q32", + "findings": [ + "F08" + ] + }, + "scenario": { + "preconditions": [ + "QualificationTarget x86 or gtutorial-i386.exe selected on the x64 host." + ], + "operation": "Observe the target profile and width.", + "expected": "X86 is reported with a 4-byte pointer width.", + "expectedCategory": "Effect" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q32.c", + "parent": "Q32", + "title": "x86 Cheat Engine host", + "titleFr": "Hôte/cible x86, x64, ARM ou backend inconnu", + "owner": "Both", + "requiredLevels": [ + "C3" + ], + "blocks": "Runtime", + "audit": { + "ref": "analyses/20 Q32", + "findings": [ + "F08" + ] + }, + "scenario": { + "preconditions": [ + "An x86 Cheat Engine executable." + ], + "operation": "Load the SDK plugin into it.", + "expected": "Unsupported: the SDK is x64-only, so the route is refused.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "The SDK and its native bridge are x64-only; an x86 Cheat Engine host (cheatengine-i386.exe) cannot load them and is an unsupported route of the profile." + } + } + }, + { + "id": "Q32.d", + "parent": "Q32", + "title": "ARM or unknown backend", + "titleFr": "Hôte/cible x86, x64, ARM ou backend inconnu", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Runtime", + "audit": { + "ref": "analyses/20 Q32", + "findings": [ + "F08" + ] + }, + "scenario": { + "preconditions": [ + "Facts that describe an ARM target or no known architecture." + ], + "operation": "Derive the target profile from them.", + "expected": "Unknown is reported instead of a guessed instruction set.", + "expectedCategory": "Unknown" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "No ARM host and no unknown backend exist in the lab; the C1 cell covers the unknown-value behaviour." + } + } + }, + { + "id": "Q33", + "parent": null, + "title": "Memory batch that fails after several writes", + "titleFr": "Lot mémoire avec échec après plusieurs écritures", + "owner": "Client", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Client", + "audit": { + "ref": "analyses/20 Q33", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A Client memory batch whose later write fails." + ], + "operation": "Run the batch.", + "expected": "The number of completed writes and the partial effect are exposed.", + "expectedCategory": "Partial" + }, + "levels": { + "C1": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "justification": "Client-owned scenario: the SDK has no Client composition to test. It is qualified in the Client matrix, https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json." + }, + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "Client-owned scenario: the SDK has no Client composition to test. It is qualified in the Client matrix, https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json." + } + } + }, + { + "id": "Q34", + "parent": null, + "title": "Record destroyed or table reloaded", + "titleFr": "Record détruit ou table rechargée", + "owner": "Both", + "requiredLevels": [ + "C3" + ], + "blocks": "Tables", + "audit": { + "ref": "analyses/20 Q34", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A memory record owner exists; the record is destroyed or the table reloaded." + ], + "operation": "Use the old reference after the destruction or reload.", + "expected": "The old reference is never reused as a new record.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q35", + "parent": null, + "title": "Script activation, then incomplete rollback", + "titleFr": "Activation de script puis rollback incomplet", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Patches", + "audit": { + "ref": "analyses/20 Q35", + "findings": [] + }, + "scenario": { + "preconditions": [ + "An auto-assembler script is activated and its rollback fails part-way." + ], + "operation": "Activate the script, then deactivate it with a failing step.", + "expected": "The disable token and the unconfirmed effects are kept and reported.", + "expectedCategory": "Partial" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q36", + "parent": null, + "title": "One-shot timer finished before Dispose", + "titleFr": "Timer one-shot terminé avant Dispose", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Événements", + "audit": { + "ref": "analyses/20 Q36", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A one-shot timer created with createTimer(delay, function) has already fired." + ], + "operation": "Dispose its token after it fired.", + "expected": "The timer object is never destroyed a second time.", + "expectedCategory": "Effect" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q37", + "parent": null, + "title": "Hotkey callback during module shutdown", + "titleFr": "Callback hotkey pendant arrêt de module", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Événements", + "audit": { + "ref": "analyses/20 Q37", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A hotkey keeps firing while the plugin disables." + ], + "operation": "Trigger the hotkey during disable.", + "expected": "The callback is either still rooted or neutralized; it never runs against freed state.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q38", + "parent": null, + "title": "Debug or process-watcher event on a secondary thread", + "titleFr": "Événement debug / process watcher sur thread secondaire", + "owner": "SDK", + "requiredLevels": [ + "C3" + ], + "blocks": "Plugin natif", + "audit": { + "ref": "analyses/20 Q38", + "findings": [ + "F09" + ] + }, + "scenario": { + "preconditions": [ + "A native plugin route that registers a debug-event or process-watcher callback." + ], + "operation": "Deliver the event on a secondary thread.", + "expected": "The signature and thread affinity are measured and the GUI is never touched illegally.", + "expectedCategory": "Unknown" + }, + "levels": { + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "Profile decision CPA-2: the managed-hostfxr route has no classic native registration, so a debug or process-watcher event never reaches an SDK plugin on this profile (the classic dispatcher is internal). Reopen if a native route is decided; the C1 dispatcher tests stay with their owner." + } + } + }, + { + "id": "Q39", + "parent": null, + "title": "Reduced or mutated classic exports table", + "titleFr": "Réduction / mutation de table d’exports classique", + "owner": "SDK", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Plugin natif", + "audit": { + "ref": "analyses/20 Q39", + "findings": [ + "F09" + ] + }, + "scenario": { + "preconditions": [ + "A classic exports table whose declared size is smaller than the SDK prefix or whose buffer is truncated." + ], + "operation": "Copy the table prefix the SDK reads.", + "expected": "No slot beyond the declared size and no nil slot is called; a reduced or truncated table is refused.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "Passed", + "passKind": "RefusalVerified", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs", + "test": "ClassicExportedFunctionsPrefixReaderTests.TryCopy_rejects_an_empty_table_representation", + "trait": "Qualification=Q39" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs", + "test": "ClassicExportedFunctionsPrefixReaderTests.TryCopy_rejects_a_buffer_that_cannot_contain_the_declared_size_field", + "trait": "Qualification=Q39" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs", + "test": "ClassicExportedFunctionsPrefixReaderTests.TryCopy_rejects_a_truncated_declared_table", + "trait": "Qualification=Q39" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs", + "test": "ClassicExportedFunctionsPrefixReaderTests.TryCopy_rejects_a_physically_truncated_table_even_when_its_size_claim_is_sufficient", + "trait": "Qualification=Q39" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj", + "file": "tests/CheatEngine.SDK.Abi.Tests/Native/ClassicExportedFunctionsPrefixReaderTests.cs", + "test": "ClassicExportedFunctionsPrefixReaderTests.TryCopy_copies_exactly_the_qualified_prefix_without_overflow", + "trait": "Qualification=Q39" + } + ], + "date": "2026-09-23" + }, + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "Profile decision CPA-2: a managed-hostfxr plugin receives the managed exports record, never the classic exports table, whose route stays documentary in 2.0. The C1 refusal tests cover the SDK reader." + } + } + }, + { + "id": "Q40", + "parent": null, + "title": "Clean installation from the package", + "titleFr": "Installation propre depuis paquet", + "owner": "Both", + "requiredLevels": [ + "C3" + ], + "blocks": "Distribution", + "audit": { + "ref": "analyses/20 Q40", + "findings": [ + "F05" + ] + }, + "scenario": { + "preconditions": [ + "A plugin folder built from the exact CI package with an isolated NuGet package folder, outside any workspace." + ], + "operation": "Load the plugin from that folder and record where every SDK assembly and the bridge were loaded from.", + "expected": "Nothing is taken implicitly from a development workspace; the bridge equals the packaged one.", + "expectedCategory": "Effect" + }, + "levels": { + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q41", + "parent": null, + "title": "NativeAOT publish and export inspection", + "titleFr": "Publication AOT et inspection des exports", + "owner": "SDK", + "requiredLevels": [ + "C0", + "C2" + ], + "blocks": "AOT", + "audit": { + "ref": "analyses/20 Q41", + "findings": [ + "F02" + ] + }, + "scenario": { + "preconditions": [ + "A NativeAOT library publish of an SDK consumer." + ], + "operation": "Publish it and inspect the exports of the native image.", + "expected": "The expected export surface is present and no Cheat Engine load promise is added.", + "expectedCategory": "Effect" + }, + "levels": { + "C0": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C2": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + } + } + }, + { + "id": "Q42", + "parent": null, + "title": "Removal of a NativeAOT plugin profile", + "titleFr": "Retrait d’un éventuel profil plugin NativeAOT", + "owner": "SDK", + "requiredLevels": [ + "C3", + "C4" + ], + "blocks": "AOT", + "audit": { + "ref": "analyses/20 Q42", + "findings": [ + "F02" + ] + }, + "scenario": { + "preconditions": [ + "A NativeAOT plugin loaded by Cheat Engine." + ], + "operation": "Unload it through the host plugin removal.", + "expected": "The residence model complies with the runtime limitations and is validated before any support is announced.", + "expectedCategory": "Refused" + }, + "levels": { + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "Profile decision CPA-2 and F02: the NativeAOT plugin route is unsupported; .NET does not support unloading a NativeAOT library with FreeLibrary (https://learn.microsoft.com/dotnet/core/deploying/native-aot/libraries)." + }, + "C4": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "Profile decision CPA-2 and F02: the NativeAOT plugin route is unsupported, so there is no second NativeAOT plugin to remove next to another one." + } + } + }, + { + "id": "Q43", + "parent": null, + "title": "Client cleanup with a faulty module", + "titleFr": "Nettoyage Client avec module fautif", + "owner": "Client", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Hosting Client", + "audit": { + "ref": "analyses/20 Q43", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A Client host with several modules, one of which fails during cleanup." + ], + "operation": "Stop the Client host.", + "expected": "Every later cleanup is still attempted and the errors are aggregated.", + "expectedCategory": "Partial" + }, + "levels": { + "C1": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "justification": "Client-owned scenario: the SDK has no Client composition to test. It is qualified in the Client matrix, https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json." + }, + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "Client-owned scenario: the SDK has no Client composition to test. It is qualified in the Client matrix, https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json." + } + } + }, + { + "id": "Q44", + "parent": null, + "title": "A contract-only API is called", + "titleFr": "Une API contract-only est appelée", + "owner": "Client", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Capacités", + "audit": { + "ref": "analyses/20 Q44", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A Client API that is declared but not implemented on this package." + ], + "operation": "Call it.", + "expected": "An explicit unavailable result is returned, never a false success.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "justification": "Client-owned scenario: the SDK has no Client composition to test. It is qualified in the Client matrix, https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json." + }, + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "Client-owned scenario: the SDK has no Client composition to test. It is qualified in the Client matrix, https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json." + } + } + }, + { + "id": "Q45", + "parent": null, + "title": "Sensitive availability probe", + "titleFr": "Probe de disponibilité sensible", + "owner": "Client", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Capacités", + "audit": { + "ref": "analyses/20 Q45", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A Client capability probe for an optional family." + ], + "operation": "Probe the capability.", + "expected": "No driver is loaded, nothing is executed remotely and the target is not changed.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "justification": "Client-owned scenario: the SDK has no Client composition to test. It is qualified in the Client matrix, https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json." + }, + "C3": { + "status": "NotApplicable", + "evidenceKind": "ProposedDecision", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "Client-owned scenario: the SDK has no Client composition to test. It is qualified in the Client matrix, https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json." + } + } + }, + { + "id": "Q46", + "parent": null, + "title": "Logs containing user data or expressions", + "titleFr": "Logs contenant données utilisateur ou expressions", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Diagnostic", + "audit": { + "ref": "analyses/20 Q46", + "findings": [] + }, + "scenario": { + "preconditions": [ + "Operations whose arguments contain user data or Lua expressions." + ], + "operation": "Log them at every level.", + "expected": "Sensitive fields are excluded unless explicitly opted in.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q47", + "parent": null, + "title": "Inherited property or method and public alias", + "titleFr": "Propriété/méthode héritée ou alias public", + "owner": "SDK", + "requiredLevels": [ + "C2", + "C3" + ], + "blocks": "Couverture", + "audit": { + "ref": "analyses/20 Q47", + "findings": [] + }, + "scenario": { + "preconditions": [ + "A Cheat Engine class that inherits a member or exposes it through an alias." + ], + "operation": "Resolve and call the member through the SDK projection.", + "expected": "The resolution matches the host and the provenance of the alias is kept.", + "expectedCategory": "Effect" + }, + "levels": { + "C2": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + }, + { + "id": "Q48", + "parent": null, + "title": "SDK package updated without adapting the Client", + "titleFr": "Paquet SDK mis à jour sans adaptation Client", + "owner": "Both", + "requiredLevels": [ + "C1", + "C3" + ], + "blocks": "Versionnement", + "audit": { + "ref": "analyses/20 Q48", + "findings": [ + "F05" + ] + }, + "scenario": { + "preconditions": [ + "A newer SDK package than the one the Client was built against." + ], + "operation": "Build the Client consumer contracts against it.", + "expected": "The gap is detected by the consumer contract tests before publication, not discovered after it.", + "expectedCategory": "Refused" + }, + "levels": { + "C1": { + "status": "NotExecuted", + "evidenceKind": "ToQualify" + }, + "C3": { + "status": "NotExecuted", + "evidenceKind": "ToQualify", + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + } + } + } + ] +} diff --git a/docs/qualification/support-profile.md b/docs/qualification/support-profile.md index 5c4b2007..207d170c 100644 --- a/docs/qualification/support-profile.md +++ b/docs/qualification/support-profile.md @@ -152,4 +152,57 @@ The list of missing evidence (audit Checkpoint A deliverable): every matrix row `SupportProfileTests.Not_executed_section_equals_the_matrix`. +| Row | Scenario | Owner | Not executed at | +|-----|----------|-------|-----------------| +| Q02 | Compact bootstrap record between guard bytes | SDK | C3 | +| Q03 | Reduced managed exports table or missing pointer | SDK | C3 | +| Q04 | Observation of the second bootstrap integer | SDK | C3 | +| Q05 | Name before enable, then enable, disable and enable | SDK | C3 | +| Q05.a | Non-ASCII plugin name | SDK | C3 | +| Q06 | Exception during construction or enable | SDK | C3 | +| Q07 | Re-entrant disable from a running callback | SDK | C3 | +| Q08 | Partial cleanup, then diagnosis | SDK | C3 | +| Q08.a | Plugin disabled between an allocation and its publication | SDK | C3 | +| Q09 | Two plugins sharing or not sharing the SDK assemblies | Both | C4 | +| Q09.a | Coexistence with one shared SDK assemblies folder | Both | C4 | +| Q09.b | Coexistence with separate plugin folders | Both | C4 | +| Q10 | Two package versions in separate folders | Both | C4 | +| Q13 | Failure of string, table, userdata or reference creation | SDK | C2 | +| Q14 | Managed callback that throws | SDK | C3 | +| Q15 | Callback kept after disable | SDK | C3 | +| Q16 | Global collision, replacement, then third-party replacement | Both | C4 | +| Q17 | Controlled Lua state replacement | SDK | C3 | +| Q18 | Use of a reference after an external reset | SDK | C3 | +| Q19 | First calls from two workers | SDK | C3, C4 | +| Q20 | Bytes with NUL and multibyte strings | Both | C1, C2, C3 | +| Q21 | Signed and unsigned 32-bit values and 64-bit boundaries | Both | C1, C2, C3 | +| Q22 | nil, false, zero, zero results and Lua error | SDK | C2, C3 | +| Q23 | CE object, light userdata and foreign userdata | SDK | C2, C3 | +| Q24 | Bound method and 0-based indexers | SDK | C2, C3 | +| Q25 | Scanner created, then list creation or publication fails | SDK | C1, C3 | +| Q26 | FirstScan, NextScan, results and destruction | SDK | C3 | +| Q27 | AOB scan: empty, error and malformed result | Both | C1, C3 | +| Q28 | AOB scan in a module with matches outside the module | Both | C1, C3 | +| Q29 | Result limit and cancellation during copy | Both | C1, C3 | +| Q30 | Reused PID and target switch | Both | C3, C4 | +| Q30.a | Targets A and B and a reused PID | Both | C3, C4 | +| Q30.b | Cleanup after a target switch | Both | C3, C4 | +| Q30.c | File opened as a process | Both | C1 | +| Q30.d | CEServer target | Both | C1 | +| Q30.e | Reuse of an old allocation address | Both | C1, C3 | +| Q31 | Overridden Cheat Engine pointer size | Both | C3 | +| Q31.a | Configured pointer size smaller than the process width | Both | C1, C3 | +| Q32 | x86, x64, ARM or unknown host and target backends | Both | C1, C3 | +| Q32.a | x64 target | Both | C1, C3 | +| Q32.b | x86 target | Both | C1, C3 | +| Q32.d | ARM or unknown backend | Both | C1 | +| Q34 | Record destroyed or table reloaded | Both | C3 | +| Q35 | Script activation, then incomplete rollback | SDK | C3 | +| Q36 | One-shot timer finished before Dispose | SDK | C3 | +| Q37 | Hotkey callback during module shutdown | SDK | C3 | +| Q40 | Clean installation from the package | Both | C3 | +| Q41 | NativeAOT publish and export inspection | SDK | C0, C2 | +| Q46 | Logs containing user data or expressions | Both | C1, C3 | +| Q47 | Inherited property or method and public alias | SDK | C2, C3 | +| Q48 | SDK package updated without adapting the Client | Both | C1, C3 | diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationMatrixTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationMatrixTests.cs new file mode 100644 index 00000000..4e95b30e --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationMatrixTests.cs @@ -0,0 +1,446 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +using CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +namespace CheatEngine.SDK.Repository.Tests.Qualification; + +/// +/// docs/qualification/matrix.json: Q01 to Q48 of the audit (analyses/20) with their required levels, one cell per +/// level, and evidence that is honest by construction. A C0-C2 cell passes only on traited tests of a CI module, a +/// C3/C4 cell only on committed receipts of the qualifiable profile, and a parent row equals its sub-rows' aggregate. +/// +public sealed class QualificationMatrixTests +{ + /// The audit's required levels (analyses/20, column "Niveau"): the oracle the matrix must reproduce. + private static readonly Dictionary AuditRequiredLevels = BuildOracle(); + + private static readonly string[] SubRows = + [ + "Q05.a", "Q08.a", "Q09.a", "Q09.b", "Q30.a", "Q30.b", "Q30.c", "Q30.d", "Q30.e", "Q31.a", "Q32.a", "Q32.b", + "Q32.c", "Q32.d" + ]; + + private static JsonElement MatrixJson => QualificationDocuments.LoadJson(QualificationDocuments.MatrixPath); + + private static QualificationMatrix Matrix => QualificationMatrix.Read(MatrixJson); + + private static JsonElement SupportProfile => QualificationDocuments.LoadJson(QualificationDocuments.SupportProfilePath); + + [Fact] + public void Matrix_matches_its_v0_schema() + { + IReadOnlyList errors = + QualificationDocuments.Schema(QualificationContract.MatrixSchemaFile).Validate(MatrixJson); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + Assert.Equal(QualificationContract.Repository, Matrix.Repository); + Assert.Equal(QualificationContract.AuditManifestSha256, + MatrixJson.GetProperty("audit").GetProperty("manifestSha256").GetString()); + Assert.Equal([QualificationContract.DocumentaryProfileId, QualificationContract.QualifiableProfileId], + Matrix.Profiles); + IReadOnlyList structure = QualificationRules.Structure(Matrix); + Assert.True(structure.Count == 0, string.Join(Environment.NewLine, structure)); + } + + [Fact] + public void Matrix_lists_Q01_to_Q48_exactly_once_with_the_audit_required_levels() + { + List topRows = []; + List subRows = []; + foreach (QualificationMatrix.Row row in Matrix.Rows) + { + (row.Parent is null ? topRows : subRows).Add(row.Id); + } + + Assert.Equal([.. Enumerable.Range(1, 48).Select(static n => "Q" + n.ToString("00", System.Globalization.CultureInfo.InvariantCulture))], + topRows); + Assert.Equal(SubRows, subRows, StringComparer.Ordinal); + foreach (string id in topRows) + { + Assert.True(AuditRequiredLevels[id].SequenceEqual(Matrix.Find(id)!.RequiredLevels, StringComparer.Ordinal), + $"{id}: requiredLevels [{string.Join(", ", Matrix.Find(id)!.RequiredLevels)}] differ from the audit [{string.Join(", ", AuditRequiredLevels[id])}]."); + } + } + + [Fact] + public void Every_required_level_has_a_cell() + { + List missing = []; + foreach (QualificationMatrix.Row row in Matrix.Rows) + { + foreach (string level in row.RequiredLevels) + { + if (!row.Levels.ContainsKey(level)) + { + missing.Add(row.Id + " " + level); + } + } + } + + Assert.True(missing.Count == 0, "Required levels without a cell: " + string.Join(", ", missing)); + } + + [Fact] + public void Every_row_declares_a_scenario_with_an_expected_category() + { + foreach (QualificationMatrix.Row row in Matrix.Rows) + { + Assert.NotEmpty(row.Preconditions); + Assert.False(string.IsNullOrWhiteSpace(row.Operation), row.Id + " has no operation."); + Assert.False(string.IsNullOrWhiteSpace(row.Expected), row.Id + " has no expected result."); + Assert.Contains(row.ExpectedCategory, QualificationContract.ExpectedCategories, StringComparer.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(row.TitleFr), row.Id + " has no audit wording."); + } + + Assert.Equal("Effect", Matrix.Find("Q05.a")!.ExpectedCategory); + Assert.Equal("Refused", Matrix.Find("Q08.a")!.ExpectedCategory); + Assert.Equal("Refused", Matrix.Find("Q30.c")!.ExpectedCategory); + Assert.Equal("Refused", Matrix.Find("Q30.d")!.ExpectedCategory); + } + + [Fact] + public void Not_applicable_cells_carry_a_justification() + { + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in Matrix.Cells()) + { + if (string.Equals(cell.Status, "NotApplicable", StringComparison.Ordinal)) + { + Assert.False(string.IsNullOrWhiteSpace(cell.Justification), $"{row.Id} {cell.Level} has no justification."); + } + } + + Assert.NotEmpty(QualificationDocuments.Schema(QualificationContract.MatrixSchemaFile) + .Validate(MutateCell("Q38", "C3", static cell => cell.Remove("justification")))); + } + + [Fact] + public void Pass_kind_is_present_exactly_when_the_cell_passed() + { + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in Matrix.Cells()) + { + bool passed = string.Equals(cell.Status, "Passed", StringComparison.Ordinal); + Assert.True(passed == (cell.PassKind is not null), $"{row.Id} {cell.Level}: {cell.Status} with passKind {cell.PassKind}."); + } + + JsonSchemaSubset schema = QualificationDocuments.Schema(QualificationContract.MatrixSchemaFile); + Assert.NotEmpty(schema.Validate(MutateCell("Q02", "C1", static cell => cell.Remove("passKind")))); + Assert.NotEmpty(schema.Validate(MutateCell("Q02", "C3", static cell => cell["passKind"] = "Functional"))); + } + + [Fact] + public void Host_level_cells_cite_the_qualifiable_profile_only() + { + IReadOnlyList errors = QualificationRules.Profiles(Matrix, SupportProfile); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in Matrix.Cells()) + { + if (cell.IsHostLevel) + { + Assert.True(string.Equals(cell.ProfileId, QualificationContract.QualifiableProfileId, StringComparison.Ordinal), + $"{row.Id} {cell.Level} cites {cell.ProfileId}."); + } + } + + Assert.Contains(QualificationRules.Profiles(Read(MutateCell("Q02", "C3", + static cell => cell["profileId"] = QualificationContract.DocumentaryProfileId)), SupportProfile), + static error => error.Contains("qualifiable", StringComparison.Ordinal)); + } + + [Fact] + public void Public_source_profile_is_refused_for_any_passed_or_failed_cell() + { + JsonElement fixtureCell = MutateCell("Q01", "C1", + static cell => cell["profileId"] = QualificationContract.DocumentaryProfileId); + JsonElement failedHostCell = MutateCell("Q04", "C3", static cell => + { + cell["status"] = "Failed"; + cell["profileId"] = QualificationContract.DocumentaryProfileId; + }); + + Assert.Contains(QualificationRules.Profiles(Read(fixtureCell), SupportProfile), + static error => error.Contains("documentary profile", StringComparison.Ordinal)); + Assert.Contains(QualificationRules.Profiles(Read(failedHostCell), SupportProfile), + static error => error.Contains("documentary profile", StringComparison.Ordinal)); + } + + [Fact] + public void Host_level_passed_or_failed_cells_cite_committed_receipts_with_matching_hashes() + { + IReadOnlyList errors = QualificationRules.Receipts(Matrix, QualificationRules.LoadCommittedReceipt); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + JsonElement uncommitted = MutateCell("Q04", "C3", static cell => + { + cell["status"] = "Passed"; + cell["passKind"] = "Functional"; + cell["evidenceKind"] = "ObservedHost"; + cell["evidence"] = ReceiptEvidence("R-20260924T101530Z-Q04-bfa967cc", "Q04"); + cell["treeHash"] = "40d7d7f741856c7e372e94bbd8532b06651eff5b"; + cell["nupkgSha256"] = "bfa967cc650ad859e5fd3164b53ae2081b6165fd5f2fa16af08b89621dfb70a4"; + cell["date"] = "2026-09-24"; + }); + Assert.Contains(QualificationRules.Receipts(Read(uncommitted), QualificationRules.LoadCommittedReceipt), + static error => error.Contains("is not committed", StringComparison.Ordinal)); + } + + [Fact] + public void C1_or_C2_evidence_is_never_accepted_for_a_host_level_cell() + { + JsonElement hostCellWithTests = MutateCell("Q02", "C3", static cell => + { + cell["status"] = "Passed"; + cell["passKind"] = "Functional"; + cell["evidenceKind"] = "ObservedHost"; + cell["evidence"] = JsonNode.Parse(MatrixJson.GetProperty("rows")[1].GetProperty("levels").GetProperty("C1") + .GetProperty("evidence").GetRawText()); + cell["treeHash"] = "40d7d7f741856c7e372e94bbd8532b06651eff5b"; + cell["nupkgSha256"] = "bfa967cc650ad859e5fd3164b53ae2081b6165fd5f2fa16af08b89621dfb70a4"; + cell["date"] = "2026-09-24"; + }); + + IReadOnlyList errors = + QualificationDocuments.Schema(QualificationContract.MatrixSchemaFile).Validate(hostCellWithTests); + + Assert.Contains(errors, static error => error.Contains("/levels/C3/evidence/0", StringComparison.Ordinal)); + } + + [Fact] + public void Automated_evidence_resolves_to_a_traited_method_in_a_CI_test_module() + { + IReadOnlySet projects = QualificationDocuments.SolutionProjects(); + + IReadOnlyList errors = QualificationRules.AutomatedEvidence(Matrix, projects); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + Assert.Contains(QualificationRules.AutomatedEvidence(Read(MutateEvidence("Q02", "C1", + static evidence => evidence["test"] = "PluginInitRecordTests.Missing_method")), projects), + static error => error.Contains("no method Missing_method", StringComparison.Ordinal)); + Assert.Contains(QualificationRules.AutomatedEvidence(Read(MutateEvidence("Q03", "C1", + static evidence => evidence["test"] = "EnablePluginTests.Enable_before_the_bootstrap_fails")), projects), + static error => error.Contains("has no [Trait", StringComparison.Ordinal)); + Assert.Contains(QualificationRules.AutomatedEvidence(Read(MutateEvidence("Q02", "C1", static evidence => + { + evidence["project"] = "tests/CheatEngine.SDK.LiveProbe.Tests/CheatEngine.SDK.Missing.Tests.csproj"; + })), projects), + static error => error.Contains("not a *.Tests module", StringComparison.Ordinal)); + } + + [Fact] + public void Every_Qualification_trait_in_the_tests_appears_in_the_matrix_and_vice_versa() + { + TestSourceIndex index = TestSourceIndex.Scan(); + + IReadOnlyList errors = QualificationRules.TraitParity(Matrix, index); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + Assert.True(index.Traits.Count >= 50, $"Only {index.Traits.Count} Qualification traits were found; the scan is broken."); + Assert.Contains(index.Traits, static trait => + string.Equals(trait.Test, "ReentrancyTests.Disable_nested_in_OnDisable_is_refused_and_OnDisable_runs_once", + StringComparison.Ordinal) && string.Equals(trait.Value, "Q07", StringComparison.Ordinal)); + } + + [Fact] + public void Parent_rows_aggregate_their_sub_rows() + { + IReadOnlyList errors = QualificationRules.Aggregation(Matrix); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + Assert.Equal("Failed", QualificationRules.Aggregate(["Passed", "Failed", "NotExecuted"])); + Assert.Equal("Passed", QualificationRules.Aggregate(["Passed", "NotApplicable"])); + Assert.Equal("NotApplicable", QualificationRules.Aggregate(["NotApplicable", "NotApplicable"])); + Assert.Equal("NotExecuted", QualificationRules.Aggregate(["Passed", "NotExecuted", "NotApplicable"])); + Assert.Contains(QualificationRules.Aggregation(Read(MutateCell("Q30.c", "C3", static cell => + { + cell["status"] = "Failed"; + }))), + static error => error.StartsWith("Q30 C3", StringComparison.Ordinal)); + } + + [Fact] + public void Evidence_kind_follows_status_and_level() + { + IReadOnlyList errors = QualificationRules.EvidenceKinds(Matrix); + + Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + Assert.Contains(QualificationRules.EvidenceKinds(Read(MutateCell("Q02", "C1", + static cell => cell["evidenceKind"] = "ObservedHost"))), + static error => error.Contains("expected ObservedSource", StringComparison.Ordinal)); + Assert.Contains(QualificationRules.EvidenceKinds(Read(MutateCell("Q02", "C3", + static cell => cell["evidenceKind"] = "DeclaredRepo"))), + static error => error.Contains("expected ToQualify", StringComparison.Ordinal)); + } + + [Fact] + public void Cells_citing_another_tree_or_package_carry_a_transfer_justification() + { + const string Tree = "40d7d7f741856c7e372e94bbd8532b06651eff5b"; + const string ReceiptId = "R-20260924T101530Z-Q04-bfa967cc"; + JsonElement receipt = QualificationDocuments.ParseJson( + """{ "receiptId": "R-20260924T101530Z-Q04-bfa967cc", "qualificationId": "Q04", "level": "C3", "status": "Passed", "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", "repository": { "treeHash": "1111111111111111111111111111111111111111" }, "package": { "nupkgSha256": "bfa967cc650ad859e5fd3164b53ae2081b6165fd5f2fa16af08b89621dfb70a4" } }"""); + Func loader = _ => + new QualificationRules.CommittedReceipt(receipt, "862417b9e7c3720bcb3263cd873b09892d787823b6f9a0f453e42824c5a4d4b6"); + + QualificationMatrix stale = Read(MutateCell("Q04", "C3", cell => PassWithReceipt(cell, Tree, ReceiptId, null))); + QualificationMatrix transferred = Read(MutateCell("Q04", "C3", cell => PassWithReceipt(cell, Tree, ReceiptId, + "The receipt tree differs only in docs/; the qualified code and package are identical."))); + + Assert.Contains(QualificationRules.Receipts(stale, loader), + static error => error.Contains("transferJustification", StringComparison.Ordinal)); + Assert.Empty(QualificationRules.Receipts(transferred, loader)); + } + + [Fact] + public void Not_executed_cells_never_carry_evidence_or_a_date() + { + foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in Matrix.Cells()) + { + if (string.Equals(cell.Status, "NotExecuted", StringComparison.Ordinal)) + { + Assert.True(cell.Evidence.Count == 0 && cell.Date is null && cell.TreeHash is null, + $"{row.Id} {cell.Level} is NotExecuted but carries evidence, a date or a tree."); + } + } + + Assert.NotEmpty(QualificationDocuments.Schema(QualificationContract.MatrixSchemaFile).Validate( + MutateCell("Q02", "C1", static cell => cell["status"] = "NotExecuted"))); + } + + [Fact] + public void Client_owned_rows_are_not_applicable_in_the_SDK_matrix_and_point_to_the_Client_matrix() + { + foreach (QualificationMatrix.Row row in Matrix.Rows) + { + if (!string.Equals(row.Owner, "Client", StringComparison.Ordinal)) + { + continue; + } + + foreach (QualificationMatrix.Cell cell in row.Levels.Values) + { + Assert.Equal("NotApplicable", cell.Status); + Assert.Contains("https://github.com/CheatEngineNet/CheatEngine.Client/blob/main/docs/qualification/matrix.json", + cell.Justification, StringComparison.Ordinal); + } + } + + Assert.Equal(["Q33", "Q43", "Q44", "Q45"], + Matrix.Rows.Where(static row => string.Equals(row.Owner, "Client", StringComparison.Ordinal)) + .Select(static row => row.Id), StringComparer.Ordinal); + } + + [Fact] + public void Findings_link_the_rows_the_audit_register_names() + { + Dictionary expected = new(StringComparer.Ordinal) + { + ["F01"] = ["Q02", "Q03", "Q04", "Q05", "Q06", "Q07", "Q08"], + ["F02"] = ["Q41", "Q42"], + ["F03"] = ["Q09", "Q10"], + ["F04"] = ["Q19"], + ["F05"] = ["Q25", "Q26", "Q40", "Q48"], + ["F06"] = ["Q27"], + ["F07"] = ["Q28", "Q29"], + ["F08"] = ["Q31", "Q32"], + ["F09"] = ["Q38", "Q39"], + ["F11"] = ["Q23", "Q24"], + ["F12"] = ["Q15", "Q16"] + }; + + foreach ((string finding, string[] rows) in expected) + { + Assert.Equal(rows, + Matrix.Rows.Where(row => row.Parent is null && row.Findings.Contains(finding, StringComparer.Ordinal)) + .Select(static row => row.Id), StringComparer.Ordinal); + } + } + + [Fact] + public void Matrix_summary_in_the_readme_equals_the_matrix() + { + string readme = QualificationDocuments.ReadNormalizedText(QualificationDocuments.ReadmePath); + string expected = QualificationMarkdown.MatrixSummary(Matrix); + + string? actual = QualificationMarkdown.GeneratedBlock(readme, QualificationMarkdown.MatrixSummaryMarker); + + Assert.True(string.Equals(expected, actual, StringComparison.Ordinal), + $"Replace the {QualificationMarkdown.MatrixSummaryMarker} block of {QualificationDocuments.ReadmePath} with:{Environment.NewLine}{expected}"); + } + + private static Dictionary BuildOracle() + { + Dictionary oracle = new(StringComparer.Ordinal); + void Add(string[] levels, params string[] ids) + { + foreach (string id in ids) + { + oracle.Add(id, levels); + } + } + + Add(["C0", "C1"], "Q01"); + Add(["C1", "C3"], "Q02", "Q03", "Q06", "Q07", "Q08", "Q25", "Q27", "Q28", "Q29", "Q32", "Q33", "Q39", "Q43", + "Q44", "Q45", "Q46", "Q48"); + Add(["C3"], "Q04", "Q05", "Q18", "Q26", "Q31", "Q34", "Q35", "Q36", "Q37", "Q38", "Q40"); + Add(["C4"], "Q09", "Q10"); + Add(["C1", "C2"], "Q11"); + Add(["C2"], "Q12", "Q13"); + Add(["C2", "C3"], "Q14", "Q15", "Q17", "Q22", "Q23", "Q24", "Q47"); + Add(["C2", "C4"], "Q16"); + Add(["C3", "C4"], "Q19", "Q30", "Q42"); + Add(["C1", "C2", "C3"], "Q20", "Q21"); + Add(["C0", "C2"], "Q41"); + return oracle; + } + + private static QualificationMatrix Read(JsonElement matrix) + { + return QualificationMatrix.Read(matrix); + } + + private static JsonElement MutateCell(string rowId, string level, Action change) + { + JsonNode document = JsonNode.Parse(MatrixJson.GetRawText())!; + foreach (JsonNode? row in document["rows"]!.AsArray()) + { + if (string.Equals((string?) row!["id"], rowId, StringComparison.Ordinal)) + { + change(row["levels"]![level]!.AsObject()); + } + } + + return QualificationDocuments.ParseJson(document.ToJsonString()); + } + + private static JsonElement MutateEvidence(string rowId, string level, Action change) + { + return MutateCell(rowId, level, cell => change(cell["evidence"]![0]!.AsObject())); + } + + private static JsonArray ReceiptEvidence(string receiptId, string rowId) + { + return + [ + new JsonObject + { + ["kind"] = "Receipt", + ["receiptId"] = receiptId, + ["path"] = QualificationDocuments.ReceiptDirectory + "/" + rowId + "/" + receiptId + ".json", + ["sha256"] = "862417b9e7c3720bcb3263cd873b09892d787823b6f9a0f453e42824c5a4d4b6" + } + ]; + } + + private static void PassWithReceipt(JsonObject cell, string tree, string receiptId, string? transfer) + { + cell["status"] = "Passed"; + cell["passKind"] = "Functional"; + cell["evidenceKind"] = "ObservedHost"; + cell["evidence"] = ReceiptEvidence(receiptId, "Q04"); + cell["treeHash"] = tree; + cell["nupkgSha256"] = "bfa967cc650ad859e5fd3164b53ae2081b6165fd5f2fa16af08b89621dfb70a4"; + cell["transferJustification"] = transfer; + cell["date"] = "2026-09-24"; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs index 964f8b28..1e3d2599 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs @@ -163,6 +163,21 @@ public void Unsupported_routes_include_nativeaot_x86_host_and_sse4_variant() } } + [Fact] + public void Not_executed_section_equals_the_matrix() + { + QualificationMatrix matrix = + QualificationMatrix.Read(QualificationDocuments.LoadJson(QualificationDocuments.MatrixPath)); + string expected = QualificationMarkdown.NotExecuted(matrix); + + string? actual = QualificationMarkdown.GeneratedBlock( + QualificationDocuments.ReadNormalizedText(QualificationDocuments.SupportProfileMarkdownPath), + QualificationMarkdown.NotExecutedMarker); + + Assert.True(string.Equals(expected, actual, StringComparison.Ordinal), + $"Replace the {QualificationMarkdown.NotExecutedMarker} block of {QualificationDocuments.SupportProfileMarkdownPath} with:{Environment.NewLine}{expected}"); + } + [Fact] public void Qualification_documents_contain_no_absolute_local_path_or_global_percentage() { diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationDocuments.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationDocuments.cs index a4e97564..631f9f3b 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationDocuments.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationDocuments.cs @@ -151,6 +151,37 @@ internal static IReadOnlyList CommittedEventLogs() return logs; } + /// The projects listed in CheatEngine.SDK.slnx, repository-relative with forward slashes. + internal static IReadOnlySet SolutionProjects() + { + HashSet projects = new(StringComparer.Ordinal); + foreach (XElement project in XDocument.Load(RepositoryRoot.SolutionPath).Descendants("Project")) + { + string? path = (string?) project.Attribute("Path"); + if (path is not null) + { + projects.Add(path.Replace('\\', '/')); + } + } + + return projects; + } + + /// The Platform Project mapping of a solution project, or . + internal static string? SolutionPlatform(string projectPath) + { + foreach (XElement project in XDocument.Load(RepositoryRoot.SolutionPath).Descendants("Project")) + { + if (string.Equals(((string?) project.Attribute("Path"))?.Replace('\\', '/'), projectPath, + StringComparison.Ordinal)) + { + return (string?) project.Element("Platform")?.Attribute("Project"); + } + } + + return null; + } + internal static bool Exists(string repositoryRelativePath) { return File.Exists(Absolute(repositoryRelativePath)); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationMarkdown.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationMarkdown.cs new file mode 100644 index 00000000..8c839746 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationMarkdown.cs @@ -0,0 +1,103 @@ +using System.Text; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// +/// Renders the Markdown blocks generated from matrix.json: the matrix summary of docs/qualification/README.md +/// and the "Not executed" list of docs/qualification/support-profile.md. The tests compare the committed blocks +/// with these renderings and print the expected block on failure, so an author pastes it instead of editing by hand. +/// +internal static class QualificationMarkdown +{ + internal const string MatrixSummaryMarker = "matrix-summary"; + internal const string NotExecutedMarker = "not-executed"; + + /// One line per row: owner, required levels and the status of every level that has a cell. + internal static string MatrixSummary(QualificationMatrix matrix) + { + StringBuilder builder = new(); + builder.Append("| Row | Scenario | Owner | Required | C0 | C1 | C2 | C3 | C4 |\n"); + builder.Append("|-----|----------|-------|----------|----|----|----|----|----|\n"); + foreach (QualificationMatrix.Row row in matrix.Rows) + { + builder.Append("| ").Append(row.Id) + .Append(" | ").Append(row.Title) + .Append(" | ").Append(row.Owner) + .Append(" | ").Append(string.Join(", ", row.RequiredLevels)); + foreach (string level in QualificationContract.Levels) + { + builder.Append(" | ").Append(row.Levels.TryGetValue(level, out QualificationMatrix.Cell? cell) + ? Describe(cell) + : "–"); + } + + builder.Append(" |\n"); + } + + return builder.ToString(); + } + + /// Every row with at least one NotExecuted level, with those levels. + internal static string NotExecuted(QualificationMatrix matrix) + { + StringBuilder builder = new(); + builder.Append("| Row | Scenario | Owner | Not executed at |\n"); + builder.Append("|-----|----------|-------|-----------------|\n"); + foreach (QualificationMatrix.Row row in matrix.Rows) + { + List levels = []; + foreach (string level in QualificationContract.Levels) + { + if (row.Levels.TryGetValue(level, out QualificationMatrix.Cell? cell) && + string.Equals(cell.Status, "NotExecuted", StringComparison.Ordinal)) + { + levels.Add(level); + } + } + + if (levels.Count == 0) + { + continue; + } + + builder.Append("| ").Append(row.Id) + .Append(" | ").Append(row.Title) + .Append(" | ").Append(row.Owner) + .Append(" | ").Append(string.Join(", ", levels)) + .Append(" |\n"); + } + + return builder.ToString(); + } + + /// + /// The text between <!-- BEGIN GENERATED: marker --> and <!-- END GENERATED: marker -->, LF + /// newlines, without the marker lines; when a marker is missing. + /// + internal static string? GeneratedBlock(string markdown, string marker) + { + string begin = "\n"; + string end = ""; + int start = markdown.IndexOf(begin, StringComparison.Ordinal); + int stop = markdown.IndexOf(end, StringComparison.Ordinal); + if (start < 0 || stop < start) + { + return null; + } + + return markdown[(start + begin.Length)..stop]; + } + + private static string Describe(QualificationMatrix.Cell cell) + { + return cell.Status switch + { + "Passed" when string.Equals(cell.PassKind, "RefusalVerified", StringComparison.Ordinal) => + "Passed (refusal verified)", + "Passed" => "Passed", + "Failed" => "Failed", + "NotApplicable" => "Not applicable", + _ => "Not executed" + }; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationRules.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationRules.cs index 657e18c3..4d650887 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationRules.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/QualificationRules.cs @@ -176,7 +176,7 @@ internal static string Aggregate(IReadOnlyCollection statuses) /// level, status and profile, and was produced from the cell's tree and package unless the cell carries a /// transferJustification. /// - internal static IReadOnlyList Receipts(QualificationMatrix matrix, Func loadReceipt) + internal static IReadOnlyList Receipts(QualificationMatrix matrix, Func loadReceipt) { List errors = []; foreach ((QualificationMatrix.Row row, QualificationMatrix.Cell cell) in matrix.Cells()) @@ -291,8 +291,16 @@ private static void CheckRequiredLevels(QualificationMatrix.Row row, ListReads a committed receipt and its LF-normalized SHA-256, or returns . + internal static CommittedReceipt? LoadCommittedReceipt(string path) + { + return QualificationDocuments.Exists(path) + ? new CommittedReceipt(QualificationDocuments.LoadJson(path), QualificationDocuments.CommittedJsonSha256(path)) + : null; + } + private static void CheckReceiptEvidence(QualificationMatrix.Row row, QualificationMatrix.Cell cell, - QualificationMatrix.Evidence evidence, Func loadReceipt, List errors) + QualificationMatrix.Evidence evidence, Func loadReceipt, List errors) { string at = At(row, cell) + " receipt " + evidence.ReceiptId; string expectedPath = QualificationDocuments.ReceiptDirectory + "/" + row.Id + "/" + evidence.ReceiptId + ".json"; @@ -302,15 +310,14 @@ private static void CheckReceiptEvidence(QualificationMatrix.Row row, Qualificat return; } - JsonElement? loaded = loadReceipt(expectedPath); - if (loaded is not { } receipt) + if (loadReceipt(expectedPath) is not { } committed) { errors.Add($"{at}: {expectedPath} is not committed."); return; } - if (!string.Equals(QualificationDocuments.CommittedJsonSha256(expectedPath), evidence.Sha256, - StringComparison.Ordinal)) + JsonElement receipt = committed.Receipt; + if (!string.Equals(committed.Sha256, evidence.Sha256, StringComparison.Ordinal)) { errors.Add($"{at}: the cited sha256 is not the LF-normalized SHA-256 of {expectedPath}."); } @@ -396,4 +403,7 @@ private static string At(QualificationMatrix.Row row, QualificationMatrix.Cell c { return string.Create(CultureInfo.InvariantCulture, $"{row.Id} {cell.Level}"); } + + /// A receipt document with the LF-normalized SHA-256 of its committed text. + internal sealed record CommittedReceipt(JsonElement Receipt, string Sha256); } From ae54db475ad473fc2ce685aae238e51f0f88ca20 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 01:59:11 +0200 Subject: [PATCH 027/199] Add a deterministic x64/x86 qualification target The host spike scanned Cheat Engine's tutorial programs, whose global match counts moved with their loaded modules, so a scan result could not be compared with anything. Add tests/CheatEngine.SDK.QualificationTarget, a console program the runner attaches Cheat Engine to (Q25-Q32 inputs): - a 16-byte marker held as constant data of the image, eight pinned heap copies 64 bytes apart, and a region with 20000 non-overlapping repetitions of a second pattern computed at run time; - Int32 and Int64 cells that the stdin command step advances by one; - one JSON ready record (cheatengine-qualification-target/v0) with PID, architecture, pointer size, image base, every region with its pattern and a count the target measures itself, and the value addresses. It references no SDK project, writes no file except an optional ready file, and exits on exit or end of input. It is published with Native AOT for win-x64 and win-x86 because the lab machine has no x86 .NET 10 runtime; cross-publishing win-x86 from x64 works with the installed MSVC toolset (verified; https://learn.microsoft.com/dotnet/core/deploying/native-aot/#platform-architecture-restrictions). Both images hold the marker twice, which the measured count reports. The project joins the solution (the entry `dotnet sln add -s tests` writes, as one hunk), so CI compiles it. Solution restores now download the win-x86 runtime, NativeAOT runtime, apphost and ILCompiler packs (77.6 MB); DisableTransitiveFrameworkReferenceDownloads avoids a further 49 MB of ASP.NET Core and Windows Desktop packs. QualificationProjectShape tests pin the shape of the target and of LiveProbe: in the solution, never a test module, never packed, no SDK reference, x64 plugin. --- CheatEngine.SDK.slnx | 1 + ...CheatEngine.SDK.QualificationTarget.csproj | 27 +++++ .../ImageScanner.cs | 72 ++++++++++++++ .../Program.cs | 98 +++++++++++++++++++ .../README.md | 77 +++++++++++++++ .../ReadyRecord.cs | 98 +++++++++++++++++++ .../TargetLayout.cs | 63 ++++++++++++ .../TargetMemory.cs | 85 ++++++++++++++++ .../QualificationProjectShapeTests.cs | 93 ++++++++++++++++++ 9 files changed, 614 insertions(+) create mode 100644 tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj create mode 100644 tests/CheatEngine.SDK.QualificationTarget/ImageScanner.cs create mode 100644 tests/CheatEngine.SDK.QualificationTarget/Program.cs create mode 100644 tests/CheatEngine.SDK.QualificationTarget/README.md create mode 100644 tests/CheatEngine.SDK.QualificationTarget/ReadyRecord.cs create mode 100644 tests/CheatEngine.SDK.QualificationTarget/TargetLayout.cs create mode 100644 tests/CheatEngine.SDK.QualificationTarget/TargetMemory.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationProjectShapeTests.cs diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 0ad33910..8e3432a3 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -114,6 +114,7 @@ + diff --git a/tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj b/tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj new file mode 100644 index 00000000..20ad4d18 --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj @@ -0,0 +1,27 @@ + + + + + Exe + QualificationTarget + win-x64;win-x86 + true + true + true + true + false + + true + + + diff --git a/tests/CheatEngine.SDK.QualificationTarget/ImageScanner.cs b/tests/CheatEngine.SDK.QualificationTarget/ImageScanner.cs new file mode 100644 index 00000000..93c2a56c --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/ImageScanner.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +namespace QualificationTarget; + +/// +/// Counts the occurrences of a byte pattern in memory the process owns: its main image (section by section, so only +/// mapped, readable pages are read) or a pinned heap region. The counts are the ground truth the qualification driver +/// compares a Cheat Engine scan with. +/// +internal static unsafe class ImageScanner +{ + private const uint ImageScnMemRead = 0x4000_0000; + + /// The base address and mapped size of the executable image. + internal static (nint Base, int Size) MainImage() + { + using Process process = Process.GetCurrentProcess(); + ProcessModule module = process.MainModule + ?? throw new InvalidOperationException("The main module is not available."); + return (module.BaseAddress, module.ModuleMemorySize); + } + + /// Counts in every readable section of the image at . + internal static int CountInImage(nint imageBase, ReadOnlySpan pattern) + { + byte* image = (byte*) imageBase; + int peHeader = *(int*) (image + 0x3C); + byte* ntHeaders = image + peHeader; + if (*(uint*) ntHeaders != 0x0000_4550) + { + throw new InvalidOperationException("The main module does not start with a PE header."); + } + + ushort sectionCount = *(ushort*) (ntHeaders + 6); + ushort optionalHeaderSize = *(ushort*) (ntHeaders + 20); + byte* sections = ntHeaders + 24 + optionalHeaderSize; + int count = 0; + for (int index = 0; index < sectionCount; index++) + { + byte* section = sections + (index * 40); + uint virtualSize = *(uint*) (section + 8); + uint virtualAddress = *(uint*) (section + 12); + uint characteristics = *(uint*) (section + 36); + if ((characteristics & ImageScnMemRead) != 0 && virtualSize > 0) + { + count += Count(new ReadOnlySpan(image + virtualAddress, checked((int) virtualSize)), pattern); + } + } + + return count; + } + + /// Counts every start position of in . + internal static int Count(ReadOnlySpan memory, ReadOnlySpan pattern) + { + int count = 0; + int offset = 0; + while (offset <= memory.Length - pattern.Length) + { + int found = memory[offset..].IndexOf(pattern); + if (found < 0) + { + break; + } + + count++; + offset += found + 1; + } + + return count; + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/Program.cs b/tests/CheatEngine.SDK.QualificationTarget/Program.cs new file mode 100644 index 00000000..33cd9243 --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/Program.cs @@ -0,0 +1,98 @@ +using System.Globalization; + +namespace QualificationTarget; + +/// +/// Entry point. Arguments: --heap-copies N (1-1024, default 8), --repetitions M (1-1000000, default +/// 20000) and --ready-file PATH (optional copy of the ready record). Standard input commands: step +/// advances the value cells, exit (or end of input) ends the process with exit code 0. +/// +internal static class Program +{ + private const int DefaultHeapCopies = 8; + private const int DefaultRepetitions = 20_000; + + private static int Main(string[] args) + { + if (!TryParse(args, out int heapCopies, out int repetitions, out string? readyFile, out string? error)) + { + Console.Error.WriteLine(error); + return 2; + } + + TargetMemory memory = new(heapCopies, repetitions); + (nint imageBase, int imageSize) = ImageScanner.MainImage(); + int moduleMarkers = ImageScanner.CountInImage(imageBase, TargetLayout.Marker); + string ready = ReadyRecord.Create(memory, imageBase, imageSize, moduleMarkers); + Console.Out.WriteLine(ready); + Console.Out.Flush(); + if (readyFile is not null) + { + File.WriteAllText(readyFile, ready + Environment.NewLine); + } + + return RunCommands(memory); + } + + private static int RunCommands(TargetMemory memory) + { + while (Console.In.ReadLine() is { } line) + { + switch (line.Trim()) + { + case "exit": + return 0; + case "step": + memory.Step(); + Console.Out.WriteLine(ReadyRecord.Step(memory)); + break; + case "": + break; + default: + Console.Out.WriteLine(ReadyRecord.Error("Unknown command; use step or exit.")); + break; + } + + Console.Out.Flush(); + } + + // End of input: the runner closed the pipe. + return 0; + } + + private static bool TryParse(string[] args, out int heapCopies, out int repetitions, out string? readyFile, + out string? error) + { + heapCopies = DefaultHeapCopies; + repetitions = DefaultRepetitions; + readyFile = null; + error = null; + for (int index = 0; index < args.Length; index++) + { + string? value = index + 1 < args.Length ? args[index + 1] : null; + switch (args[index]) + { + case "--heap-copies" when TryRange(value, 1, 1024, out heapCopies): + case "--repetitions" when TryRange(value, 1, 1_000_000, out repetitions): + index++; + break; + case "--ready-file" when !string.IsNullOrWhiteSpace(value): + readyFile = value; + index++; + break; + default: + error = "Invalid argument '" + args[index] + + "'. Use --heap-copies 1-1024, --repetitions 1-1000000 and --ready-file PATH."; + return false; + } + } + + return true; + } + + private static bool TryRange(string? text, int minimum, int maximum, out int value) + { + return int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value) && value >= minimum && + value <= maximum; + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/README.md b/tests/CheatEngine.SDK.QualificationTarget/README.md new file mode 100644 index 00000000..0d6f53e7 --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/README.md @@ -0,0 +1,77 @@ +# CheatEngine.SDK.QualificationTarget + +A small, deterministic console program that the exact-host qualification runs attach Cheat Engine to. It is a lab +target, not a test project, not a plugin and never a package. + +## Objective + +Give the scan, memory and target scenarios of the [qualification matrix](../../docs/qualification/README.md) (Q25–Q32) +a target whose content is known in advance on x64 and on x86: a pattern inside the executable image, copies of it on the +heap, a region with many matches of a second pattern, and two value cells that change on command. Every count the +target reports is measured by the target itself, so a Cheat Engine scan can be compared with ground truth. + +## Why it exists + +The Phase-0 host spike used the tutorial programs shipped with Cheat Engine, whose global match counts changed with the +modules loaded (35 against 31 for the same pattern). A qualification result needs a target whose layout the repository +controls and whose hash each receipt records. The machine that runs the qualification has no x86 .NET 10 runtime (only +x86 .NET 6), so the target is published with Native AOT, which supports win-x86 since .NET 9 +([Native AOT platform restrictions](https://learn.microsoft.com/dotnet/core/deploying/native-aot/#platform-architecture-restrictions)). + +## How it works + +| File | Content | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `TargetLayout.cs` | The 16-byte module-resident marker (constant data of the image), the 8-byte many-results pattern (computed at run time, so it is not in the image), the initial values. | +| `TargetMemory.cs` | Pinned heap arrays: N marker copies 64 bytes apart, M back-to-back repetitions of the second pattern, and the Int32/Int64 cells. | +| `ImageScanner.cs` | Counts a pattern in every readable section of the executable image, or in a heap region. | +| `ReadyRecord.cs` | The one-line JSON records, written with `Utf8JsonWriter` (no reflection, trim-safe). | +| `Program.cs` | Arguments, the ready record, and the `step` / `exit` command loop on standard input. | + +On start the target prints one line and waits for commands: + +```json +{"schema":"cheatengine-qualification-target/v0","pid":46920,"arch":"x64","pointerSize":8,"imageBase":"0x7FF7B65F0000","imageSize":1617920, + "regions":[{"id":"module-marker","base":"0x7FF7B65F0000","length":1617920,"pattern":"C3 5D 4B 51 54 9A 7E 21 E8 0F B2 66 13 D7 4C A5","count":2}, + {"id":"heap-marker","base":"0x1E288001588","length":512,"pattern":"C3 5D 4B 51 54 9A 7E 21 E8 0F B2 66 13 D7 4C A5","count":8}, + {"id":"many-results","base":"0x1E2880017A0","length":160000,"pattern":"8F A2 F9 0C 23 76 8D A0","count":20000}], + "values":[{"id":"int32","address":"0x1E2880288B8","value":1573167383},{"id":"int64","address":"0x1E2880288C0","value":81985529216486895}]} +``` + +(Shown wrapped; the real record is one line and the addresses differ per run.) The `module-marker` count is measured +over the image's readable sections; the win-x64 and win-x86 images of the first build held it twice, because the +compiler may emit a constant more than once, which is why the count is measured and never assumed. The heap counts are +measured over their regions. Patterns that must be absent are chosen by the qualification driver, never stored in the +target. + +| Input | Effect | +|------------------------------|-----------------------------------------------------------------------------------------| +| `--heap-copies N` | Number of heap marker copies, 1–1024 (default 8). | +| `--repetitions M` | Number of many-results repetitions, 1–1000000 (default 20000). | +| `--ready-file PATH` | Also writes the ready record to PATH; the only file the target ever writes. | +| `step` on standard input | Adds one to both value cells and prints `{"event":"step","int32":…,"int64":…}`. | +| `exit` or end of input | Exits with code 0. | + +The target opens no network connection, requests no elevation, reads no file and loads no SDK assembly. + +## Promise + +- The target is in the solution, publishes with Native AOT for `win-x64` and `win-x86`, is not a test module and never + packs (`QualificationProjectShapeTests.QualificationTarget_is_in_the_solution_and_publishes_native_aot_for_x64_and_x86`, + `QualificationProjectShapeTests.Qualification_harnesses_are_not_test_modules_and_never_pack`). +- It references no SDK project, so a qualification observes the SDK only from the plugin side + (`QualificationProjectShapeTests.QualificationTarget_references_no_SDK_project`). +- The ready record and the counts are produced by the published executable; the local runner + ([`eng/qualification`](../../eng/qualification/README.md)) publishes it per run and records its SHA-256 in each receipt. + +## Run the tests + +The project has no test module of its own; its shape is checked by +`dotnet test --project tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj`. To try it: + +```powershell +dotnet publish tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj -c Release -r win-x64 -o +``` + +then run `/CheatEngine.SDK.QualificationTarget.exe`, type `step`, then `exit`. Use `-r win-x86` for the x86 +build; both need the MSVC linker that Native AOT uses on Windows. diff --git a/tests/CheatEngine.SDK.QualificationTarget/ReadyRecord.cs b/tests/CheatEngine.SDK.QualificationTarget/ReadyRecord.cs new file mode 100644 index 00000000..c50ffe1b --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/ReadyRecord.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace QualificationTarget; + +/// +/// The one-line JSON records the target prints: the ready record (schema ) on start, and one +/// step record per step command. Written with , so nothing depends on +/// reflection and the Native AOT build stays trim-safe. +/// +internal static class ReadyRecord +{ + internal const string Schema = "cheatengine-qualification-target/v0"; + + internal static string Create(TargetMemory memory, nint imageBase, int imageSize, int moduleMarkerCount) + { + string marker = TargetLayout.ToPattern(TargetLayout.Marker); + return Write(writer => + { + writer.WriteString("schema", Schema); + writer.WriteNumber("pid", Environment.ProcessId); + writer.WriteString("arch", RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant()); + writer.WriteNumber("pointerSize", IntPtr.Size); + writer.WriteString("imageBase", Hex(imageBase)); + writer.WriteNumber("imageSize", imageSize); + writer.WriteStartArray("regions"); + Region(writer, "module-marker", imageBase, imageSize, marker, moduleMarkerCount); + Region(writer, "heap-marker", memory.HeapMarkersAddress, memory.HeapMarkers.Length, marker, + ImageScanner.Count(memory.HeapMarkers, TargetLayout.Marker)); + Region(writer, "many-results", memory.RepeatedAddress, memory.Repeated.Length, + TargetLayout.ToPattern(memory.RepeatedPattern), ImageScanner.Count(memory.Repeated, memory.RepeatedPattern)); + writer.WriteEndArray(); + writer.WriteStartArray("values"); + Value(writer, "int32", memory.Int32Address, memory.Int32); + Value(writer, "int64", memory.Int64Address, memory.Int64); + writer.WriteEndArray(); + }); + } + + internal static string Step(TargetMemory memory) + { + return Write(writer => + { + writer.WriteString("event", "step"); + writer.WriteNumber("int32", memory.Int32); + writer.WriteNumber("int64", memory.Int64); + }); + } + + internal static string Error(string message) + { + return Write(writer => + { + writer.WriteString("event", "error"); + writer.WriteString("message", message); + }); + } + + private static void Region(Utf8JsonWriter writer, string id, nint address, int length, string pattern, int count) + { + writer.WriteStartObject(); + writer.WriteString("id", id); + writer.WriteString("base", Hex(address)); + writer.WriteNumber("length", length); + writer.WriteString("pattern", pattern); + writer.WriteNumber("count", count); + writer.WriteEndObject(); + } + + private static void Value(Utf8JsonWriter writer, string id, nint address, long value) + { + writer.WriteStartObject(); + writer.WriteString("id", id); + writer.WriteString("address", Hex(address)); + writer.WriteNumber("value", value); + writer.WriteEndObject(); + } + + private static string Hex(nint address) + { + return "0x" + ((nuint) address).ToString("X", CultureInfo.InvariantCulture); + } + + private static string Write(Action body) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + body(writer); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/TargetLayout.cs b/tests/CheatEngine.SDK.QualificationTarget/TargetLayout.cs new file mode 100644 index 00000000..530e7c5d --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/TargetLayout.cs @@ -0,0 +1,63 @@ +using System.Text; + +namespace QualificationTarget; + +/// +/// The fixed byte patterns and value cells of the target. Every value is a constant, so two runs of the same build +/// expose the same patterns and the same initial values; only addresses and the PID differ. +/// +internal static class TargetLayout +{ + /// Distance between two heap copies of the marker, so that copies never touch. + internal const int HeapCopyStride = 64; + + /// Length of the many-results pattern. + internal const int RepeatedPatternLength = 8; + + /// Initial value of the Int32 cell (0x5DC3A117); step adds one. + internal const int InitialInt32 = 1_573_167_383; + + /// Initial value of the Int64 cell (0x0123456789ABCDEF); step adds one. + internal const long InitialInt64 = 81_985_529_216_486_895; + + /// + /// The module-resident marker. A over a constant array is emitted as read-only data + /// of the image, so the executable itself holds it; the heap copies are made from it at run time. + /// + internal static ReadOnlySpan Marker => + [ + 0xC3, 0x5D, 0x4B, 0x51, 0x54, 0x9A, 0x7E, 0x21, 0xE8, 0x0F, 0xB2, 0x66, 0x13, 0xD7, 0x4C, 0xA5 + ]; + + /// + /// The many-results pattern. It is computed at run time, so its bytes are not stored in the image and every match + /// of it lies in the heap region. All eight bytes differ, so matches cannot overlap. + /// + internal static byte[] CreateRepeatedPattern() + { + byte[] pattern = new byte[RepeatedPatternLength]; + for (int index = 0; index < pattern.Length; index++) + { + pattern[index] = (byte) (0x9E ^ ((index * 0x2B) + 0x11)); + } + + return pattern; + } + + /// Formats bytes as the space-separated upper-case hex text of an AOB pattern. + internal static string ToPattern(ReadOnlySpan bytes) + { + StringBuilder builder = new(bytes.Length * 3); + foreach (byte value in bytes) + { + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append(value.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } +} diff --git a/tests/CheatEngine.SDK.QualificationTarget/TargetMemory.cs b/tests/CheatEngine.SDK.QualificationTarget/TargetMemory.cs new file mode 100644 index 00000000..b7958f02 --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/TargetMemory.cs @@ -0,0 +1,85 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QualificationTarget; + +/// +/// The heap side of the target: pinned arrays, so every address in the ready record stays valid for the whole run. +/// Allocated once at start; nothing is freed before exit. +/// +internal sealed unsafe class TargetMemory +{ + private readonly byte[] _heapMarkers; + private readonly byte[] _repeated; + private readonly long[] _values; + + internal TargetMemory(int heapCopies, int repetitions) + { + HeapCopies = heapCopies; + Repetitions = repetitions; + RepeatedPattern = TargetLayout.CreateRepeatedPattern(); + + _heapMarkers = GC.AllocateArray(heapCopies * TargetLayout.HeapCopyStride, pinned: true); + for (int copy = 0; copy < heapCopies; copy++) + { + TargetLayout.Marker.CopyTo(_heapMarkers.AsSpan(copy * TargetLayout.HeapCopyStride)); + } + + _repeated = GC.AllocateArray(repetitions * TargetLayout.RepeatedPatternLength, pinned: true); + for (int repetition = 0; repetition < repetitions; repetition++) + { + RepeatedPattern.CopyTo(_repeated.AsSpan(repetition * TargetLayout.RepeatedPatternLength)); + } + + // Slot 0 holds the Int32 cell in its low four bytes, slot 1 the Int64 cell; both stay naturally aligned. + _values = GC.AllocateArray(2, pinned: true); + Int32 = TargetLayout.InitialInt32; + Int64 = TargetLayout.InitialInt64; + } + + internal int HeapCopies + { + get; + } + + internal int Repetitions + { + get; + } + + internal byte[] RepeatedPattern + { + get; + } + + internal ReadOnlySpan HeapMarkers => _heapMarkers; + + internal ReadOnlySpan Repeated => _repeated; + + internal nint HeapMarkersAddress => (nint) Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(_heapMarkers)); + + internal nint RepeatedAddress => (nint) Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(_repeated)); + + internal nint Int32Address => (nint) Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(_values)); + + internal nint Int64Address => Int32Address + sizeof(long); + + internal int Int32 + { + get => Volatile.Read(ref Unsafe.As(ref _values[0])); + private set => Volatile.Write(ref Unsafe.As(ref _values[0]), value); + } + + internal long Int64 + { + get => Volatile.Read(ref _values[1]); + private set => Volatile.Write(ref _values[1], value); + } + + /// Advances both value cells by one, the change a next scan looks for. + internal void Step() + { + Int32 = unchecked(Int32 + 1); + Int64 = unchecked(Int64 + 1); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationProjectShapeTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationProjectShapeTests.cs new file mode 100644 index 00000000..2e902650 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationProjectShapeTests.cs @@ -0,0 +1,93 @@ +using CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +namespace CheatEngine.SDK.Repository.Tests.Qualification; + +/// +/// The two qualification harnesses are compiled by CI through the solution, never run or packed by it: the CE 7.7 +/// live probe is an x64 dynamic-loading plugin, and the qualification target is a Native AOT console program for x64 +/// and x86 that references no SDK project. Static checks of the solution and project files; nothing is built here. +/// +public sealed class QualificationProjectShapeTests +{ + private const string LiveProbe = "tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj"; + private const string Target = "tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj"; + + [Fact] + public void LiveProbe_is_in_the_solution_as_an_x64_dynamic_loading_plugin_that_never_packs() + { + XElement project = Project(LiveProbe); + + Assert.Contains(LiveProbe, QualificationDocuments.SolutionProjects()); + Assert.Equal("x64", QualificationDocuments.SolutionPlatform(LiveProbe)); + Assert.Equal("x64", Property(project, "Platforms")); + Assert.Equal("x64", Property(project, "PlatformTarget")); + Assert.Equal("true", Property(project, "EnableDynamicLoading")); + Assert.Equal("false", Property(project, "IsPackable")); + Assert.False(Path.GetFileNameWithoutExtension(LiveProbe).EndsWith(".Tests", StringComparison.Ordinal)); + } + + [Fact] + public void QualificationTarget_is_in_the_solution_and_publishes_native_aot_for_x64_and_x86() + { + XElement project = Project(Target); + + Assert.Contains(Target, QualificationDocuments.SolutionProjects()); + Assert.Equal("Exe", Property(project, "OutputType")); + Assert.Equal("true", Property(project, "PublishAot")); + Assert.Equal(["win-x64", "win-x86"], + (Property(project, "RuntimeIdentifiers") ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries)); + Assert.Null(Property(project, "RuntimeIdentifier")); + Assert.True(QualificationDocuments.Exists("tests/CheatEngine.SDK.QualificationTarget/README.md")); + } + + [Fact] + public void Qualification_harnesses_are_not_test_modules_and_never_pack() + { + foreach (string path in (string[]) [LiveProbe, Target]) + { + XElement project = Project(path); + + Assert.False(Path.GetFileNameWithoutExtension(path).EndsWith(".Tests", StringComparison.Ordinal), + $"{path}: eng/Tests.props turns every *.Tests project into a test module."); + Assert.Equal("false", Property(project, "IsPackable")); + Assert.Null(Property(project, "IsTestProject")); + Assert.DoesNotContain(project.Descendants("PackageReference"), + static reference => ((string?) reference.Attribute("Include") ?? string.Empty).StartsWith("xunit", + StringComparison.OrdinalIgnoreCase)); + } + } + + [Fact] + public void QualificationTarget_references_no_SDK_project() + { + XElement project = Project(Target); + + Assert.Empty(project.Descendants("ProjectReference")); + Assert.Empty(project.Descendants("PackageReference")); + foreach (string source in Directory.EnumerateFiles( + QualificationDocuments.Absolute("tests/CheatEngine.SDK.QualificationTarget"), "*.cs")) + { + Assert.DoesNotContain("CheatEngine.SDK.", File.ReadAllText(source), StringComparison.Ordinal); + } + } + + private static XElement Project(string path) + { + return XDocument.Load(QualificationDocuments.Absolute(path)).Root + ?? throw new InvalidOperationException(path + " has no root element."); + } + + private static string? Property(XElement project, string name) + { + string? value = null; + foreach (XElement group in project.Elements("PropertyGroup")) + { + if (group.Attribute("Condition") is null && group.Element(name) is { } element) + { + value = element.Value.Trim(); + } + } + + return value; + } +} From 63adb076f72ffd7e835ef473b6737687eb7601f1 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 02:20:55 +0200 Subject: [PATCH 028/199] Add the local exact-host qualification runner C3/C4 evidence needs a repeatable way to run the exact Cheat Engine host without touching the installation or the operator's settings, and it must never run in CI. Add eng/qualification: - Invoke-LocalQualification.ps1: the CI guard is the first statement (exit 3); a read-only preflight compares the installed executable, Lua module, runtimeconfig and celua.txt with support-profile.json (exit 4) and refuses an elevated runner, another Cheat Engine instance, build load or a dirty tree unless explicitly allowed (exit 5); runs hold the Global\ce-lab mutex, mirror the installation into a sandbox verified file by file, build every harness from the exact package in a throw-away consumer with an isolated NUGET_PACKAGES and check its closure (plugin entry point, SDK assemblies, deps.json without project entries, runtimeconfig, packaged bridge), record the bridge fingerprint, publish the qualification target, export HKCU, drive Cheat Engine through a generated autorun driver with a watchdog and operator handshakes, clean up, restore HKCU only on a non-empty difference with no other instance running (exit 7 otherwise), and write one redacted receipt and event log per scenario, or a smoke report that is never a receipt. - QualificationRunner.psm1: the pure helpers (LF hash rule, registry export parsing and name-only diff, redaction, event-log bounding, receipt id and assembly, Lua literals, bundle closure, pass-rule evaluation) so tests can exercise them without Cheat Engine. - driver/zz_cesdk_qualification.template.lua: a recorder that runs one step per timer tick under pcall, persists its progress across a Lua state reset and appends one JSON event per line. - scenarios.json: the Checkpoint B plan (C3 Q02-Q08, Q05.a, Q14, Q15, Q18, Q19, Q40; C4 Q09.a/Q09.b), with Q17 kept Manual and Q39 NotApplicable, each with a declarative pass rule. LocalQualificationRunnerTests run pwsh (failing, never skipping, when it is absent): the guard for CI, GITHUB_ACTIONS and TF_BUILD, a scope-aware AST proof that nothing writes into the installation, strict mode, no workflow reference, receipt assembly validated by the C# receipt rules, redaction, name-only registry diffs, closure refusals, scenario/harness consistency, and every generated driver compiled with the committed Lua 5.3 module. PSScriptAnalyzer reports no warning or error. --- CheatEngine.SDK.slnx | 8 + .../Invoke-LocalQualification.ps1 | 912 ++++++++++++++++++ eng/qualification/QualificationRunner.psm1 | 894 +++++++++++++++++ .../zz_cesdk_qualification.template.lua | 244 +++++ eng/qualification/scenarios.json | 419 ++++++++ .../LocalQualificationRunnerTests.cs | 523 ++++++++++ .../Validation/LuaSyntaxChecker.cs | 72 ++ .../Validation/PowerShellProcess.cs | 110 +++ 8 files changed, 3182 insertions(+) create mode 100644 eng/qualification/Invoke-LocalQualification.ps1 create mode 100644 eng/qualification/QualificationRunner.psm1 create mode 100644 eng/qualification/driver/zz_cesdk_qualification.template.lua create mode 100644 eng/qualification/scenarios.json create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/LuaSyntaxChecker.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/PowerShellProcess.cs diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 8e3432a3..8fc8fa10 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -37,6 +37,14 @@ + + + + + + + + diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 new file mode 100644 index 00000000..50905f76 --- /dev/null +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -0,0 +1,912 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS + Runs exact-host (C3/C4) qualification scenarios on a sandbox copy of Cheat Engine 7.7 and writes redacted receipts. + +.DESCRIPTION + Local and operator-attended only: the script refuses to run in CI. It never writes to the Cheat Engine installation; + it copies it into a sandbox under -WorkRoot, verifies every file against the installation and the host facts + against docs/qualification/support-profile.json, builds the plugin harnesses from the exact CheatEngine.SDK package + (-PackagePath) in clean folders with an isolated NuGet packages folder, starts the qualification target, drives + Cheat Engine with a generated autorun Lua driver, restores HKCU\Software\Cheat Engine when a run changed it, and + writes one receipt and one event log per scenario (schemas under docs/qualification/schemas). + + Stages: guard, preflight, Global\ce-lab mutex, sandbox, bundles, bridge identity, targets, HKCU export, driver, + launch, cleanup, HKCU compare and restore, redaction and receipts, optional publication. See + eng/qualification/README.md and docs/qualification/local-protocol.md. + + Exit codes: 0 success; 2 invalid arguments; 3 CI environment; 4 host or profile mismatch; 5 unsafe environment; + 6 Cheat Engine, build or driver failure; 7 HKCU restore or verification failure (critical: the restore command and + the backup path are printed). + +.PARAMETER Scenario + Scenario ids of eng/qualification/scenarios.json (for example Q04, Q09.a) or CheckpointB for every runnable one. + +.PARAMETER PackagePath + The exact CheatEngine.SDK .nupkg to qualify: the CI artifact (gh run download -n nuget-package) or the + nuget.org file. Required unless -PreflightOnly. + +.PARAMETER PackageSource + CiArtifact (default; requires -CiRunUrl) or NuGetOrg. Local packs are never qualification inputs; use -Smoke. + +.PARAMETER Smoke + Plumbing run with a local pack: writes smoke-report.json and never a receipt. The repository may be dirty. + +.EXAMPLE + ./eng/qualification/Invoke-LocalQualification.ps1 -PreflightOnly -WhatIf + +.EXAMPLE + ./eng/qualification/Invoke-LocalQualification.ps1 -Scenario Q04,Q14 -PackagePath .\nuget-package\CheatEngine.SDK.2.0.0-alpha.0.42.nupkg -CiRunUrl https://github.com/CheatEngineNet/CheatEngine.SDK/actions/runs/1 -PullRequest 86 -HeadSha -Operator +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string[]] $Scenario = @('CheckpointB'), + [string] $PackagePath = '', + [ValidateSet('CiArtifact', 'NuGetOrg')] [string] $PackageSource = 'CiArtifact', + [string] $CiRunUrl = '', + [int] $PullRequest = 0, + [string] $HeadSha = '', + [string] $Operator = '', + [string] $CheatEnginePath = '', + [string] $WorkRoot = '', + [ValidateRange(30, 7200)] [int] $CeTimeoutSeconds = 900, + [ValidateRange(1, 1440)] [int] $MutexTimeoutMinutes = 30, + [switch] $PreflightOnly, + [switch] $Smoke, + [switch] $AllowConcurrentLoad, + [switch] $AllowOtherCheatEngineInstances, + [string] $PublishReceiptsTo = '' +) + +# Stage 1, guard: the first statement. Nothing above has a side effect; nothing below runs in CI. +foreach ($ciMarker in 'CI', 'GITHUB_ACTIONS', 'TF_BUILD') { + if (-not [string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($ciMarker))) { + [System.Console]::Error.WriteLine("Invoke-LocalQualification.ps1 never runs in CI ($ciMarker is set): it starts Cheat Engine on an operator's machine.") + exit 3 + } +} + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$InformationPreference = 'Continue' + +Import-Module (Join-Path $PSScriptRoot 'QualificationRunner.psm1') -Force + +$ProfileId = 'ce-7.7.0.10621-x64-managed-hostfxr' +$RegistryKey = 'HKCU\Software\Cheat Engine' +$DriverFileName = 'zz_cesdk_qualification.lua' +$LiveProbeAcknowledgement = 'I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET' +$Harnesses = [ordered]@{ + LiveProbe = @{ Sources = @('tests/CheatEngine.SDK.LiveProbe'); Assembly = 'CheatEngine.SDK.LiveProbe'; Namespace = 'LiveProbe'; Constants = ''; GenerateEntryPoint = 'false' } + LiveProbeNonAscii = @{ Sources = @('tests/CheatEngine.SDK.LiveProbe'); Assembly = 'CheatEngine.SDK.LiveProbe'; Namespace = 'LiveProbe'; Constants = 'LIVEPROBE_NON_ASCII_NAME'; GenerateEntryPoint = 'false' } + LivePlugin = @{ Sources = @('tests/CheatEngine.SDK.LivePlugin'); Assembly = 'CheatEngine.SDK.LivePlugin'; Namespace = 'LivePlugin'; Constants = ''; GenerateEntryPoint = 'true' } + CoexistenceA = @{ Sources = @('tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA', 'tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs'); Assembly = 'CheatEngine.SDK.LivePlugin.Coexistence.PluginA'; Namespace = 'LivePlugin.Coexistence.PluginA'; Constants = ''; GenerateEntryPoint = 'true' } + CoexistenceB = @{ Sources = @('tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB', 'tests/CheatEngine.SDK.LivePlugin.Coexistence/CoexistenceDiagnostics.cs'); Assembly = 'CheatEngine.SDK.LivePlugin.Coexistence.PluginB'; Namespace = 'LivePlugin.Coexistence.PluginB'; Constants = ''; GenerateEntryPoint = 'true' } +} + +function Exit-Qualification { + <# + .SYNOPSIS + Writes the reason to standard error and exits with the documented code (finally blocks still run). + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [int] $Code, [Parameter(Mandatory)] [string] $Reason) + + [System.Console]::Error.WriteLine("Invoke-LocalQualification.ps1: $Reason (exit $Code)") + exit $Code +} + +function Invoke-Native { + <# + .SYNOPSIS + Runs a native command, captures its output and throws on a failing exit code (robocopy: 8 and above). + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [string] $FilePath, + [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $ArgumentList, + [int] $FailureThreshold = 1 + ) + + $output = @(& $FilePath @ArgumentList 2>&1 | Where-Object { $null -ne $_ } | ForEach-Object { "$_" }) + if ($LASTEXITCODE -ge $FailureThreshold) { + throw "$FilePath $($ArgumentList -join ' ') failed with exit code $LASTEXITCODE`: $(($output | Select-Object -Last 20) -join [System.Environment]::NewLine)" + } + + return $output +} + +function Get-RepositoryRoot { + [CmdletBinding()] + [OutputType([string])] + param() + + $root = Invoke-Native -FilePath 'git' -ArgumentList @('-C', $PSScriptRoot, 'rev-parse', '--show-toplevel') + return [System.IO.Path]::GetFullPath(($root | Select-Object -First 1).Trim()) +} + +function Get-PeMachine { + <# + .SYNOPSIS + The COFF machine of a PE file (AMD64, I386, ...), read without loading it. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $Path) + + $stream = [System.IO.File]::OpenRead($Path) + try { + $reader = [System.Reflection.PortableExecutable.PEReader]::new($stream) + try { return $reader.PEHeaders.CoffHeader.Machine.ToString().ToUpperInvariant() } + finally { $reader.Dispose() } + } + finally { + $stream.Dispose() + } +} + +function Get-CheatEngineProcess { + [CmdletBinding()] + [OutputType([System.Diagnostics.Process[]])] + param() + + return @(Get-Process | Where-Object { $_.ProcessName -match '^(cheatengine|Cheat Engine)' }) +} + +function Invoke-Preflight { + <# + .SYNOPSIS + Stage 2, read-only: hashes the installation, compares it with the support profile and checks the environment. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [object] $SupportProfile + ) + + $expectedHost = $SupportProfile.host + $exe = Join-Path $CheatEnginePath $expectedHost.exeName + foreach ($file in @($exe, (Join-Path $CheatEnginePath $SupportProfile.lua.module), (Join-Path $CheatEnginePath 'ce.runtimeconfig.json'), (Join-Path $CheatEnginePath 'celua.txt'))) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { Exit-Qualification -Code 4 -Reason "The profiled file '$file' is missing." } + } + + $facts = [ordered]@{ + profileId = $ProfileId + ceExeName = $expectedHost.exeName + ceExeSha256 = Get-QualificationFileSha256 -Path $exe + ceFileVersion = (Get-Item -LiteralPath $exe).VersionInfo.FileVersion + ceMachine = Get-PeMachine -Path $exe + luaDllSha256 = Get-QualificationFileSha256 -Path (Join-Path $CheatEnginePath $SupportProfile.lua.module) + runtimeconfigSha256 = Get-QualificationFileSha256 -Path (Join-Path $CheatEnginePath 'ce.runtimeconfig.json') + celuaSha256 = Get-QualificationFileSha256 -Path (Join-Path $CheatEnginePath 'celua.txt') + } + $expected = [ordered]@{ + ceExeSha256 = $expectedHost.exeSha256 + ceFileVersion = $expectedHost.version + ceMachine = $expectedHost.machine + luaDllSha256 = $SupportProfile.lua.sha256 + runtimeconfigSha256 = $SupportProfile.runtime.runtimeconfig.sha256 + celuaSha256 = $SupportProfile.celua.sha256 + } + $mismatches = @(foreach ($name in $expected.Keys) { if ($facts[$name] -cne $expected[$name]) { "$name is $($facts[$name]), the profile says $($expected[$name])" } }) + $facts.matchesProfile = $mismatches.Count -eq 0 + $facts.mismatches = $mismatches + + $principal = [System.Security.Principal.WindowsPrincipal]::new([System.Security.Principal.WindowsIdentity]::GetCurrent()) + $facts.elevated = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + $facts.otherCheatEngineProcesses = @(Get-CheatEngineProcess | ForEach-Object { $_.ProcessName }) + $facts.buildProcesses = @(Get-Process -Name 'dotnet', 'MSBuild', 'VBCSCompiler' -ErrorAction SilentlyContinue).Count + $facts.repositoryDirty = @(Invoke-Native -FilePath 'git' -ArgumentList @('-C', $RepositoryRoot, 'status', '--porcelain')).Count -gt 0 + $globalJson = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'global.json') -Raw | ConvertFrom-Json + Push-Location $RepositoryRoot + try { $facts.dotnetSdk = (Invoke-Native -FilePath 'dotnet' -ArgumentList @('--version') | Select-Object -First 1).Trim() } + finally { Pop-Location } + $facts.expectedDotnetSdk = $globalJson.sdk.version + $facts.dotnetRuntimes = @(Invoke-Native -FilePath 'dotnet' -ArgumentList @('--list-runtimes') | ForEach-Object { ($_ -replace '\s*\[.*\]\s*$', '').Trim() } | Where-Object { $_ }) + $facts.osVersion = [System.Environment]::OSVersion.VersionString + return $facts +} + +function Test-PreflightSafety { + <# + .SYNOPSIS + Turns the preflight facts into the refusals of stage 2. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.IDictionary] $Facts, + [switch] $AllowOthers, + [switch] $AllowLoad, + [switch] $AllowDirty + ) + + if (-not $Facts.matchesProfile) { Exit-Qualification -Code 4 -Reason ('The installation is not the profiled host: ' + ($Facts.mismatches -join '; ')) } + if ($Facts.elevated) { Exit-Qualification -Code 5 -Reason 'The runner is elevated; qualification runs Cheat Engine as the invoking user only.' } + if ($Facts.otherCheatEngineProcesses.Count -gt 0 -and -not $AllowOthers) { + Exit-Qualification -Code 5 -Reason "Another Cheat Engine instance runs ($($Facts.otherCheatEngineProcesses -join ', ')). Close it, or pass -AllowOtherCheatEngineInstances (HKCU is then never restored automatically)." + } + if ($Facts.buildProcesses -gt 0 -and -not $AllowLoad) { + Exit-Qualification -Code 5 -Reason "$($Facts.buildProcesses) dotnet/MSBuild/VBCSCompiler process(es) run; timings would be distorted. Wait, or pass -AllowConcurrentLoad (timings are then marked indicative)." + } + if ($Facts.repositoryDirty -and -not $AllowDirty) { Exit-Qualification -Code 5 -Reason 'The repository tree is dirty; a receipt must name a committed tree.' } + if ($Facts.dotnetSdk -ne $Facts.expectedDotnetSdk) { Exit-Qualification -Code 5 -Reason "dotnet --version is $($Facts.dotnetSdk), global.json pins $($Facts.expectedDotnetSdk)." } +} + +function Get-PackageIdentity { + <# + .SYNOPSIS + Reads id and version from the package's .nuspec (never from the file name) and hashes the package and its bridge. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param([Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $ExtractTo) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + $nuspec = @($zip.Entries | Where-Object { $_.FullName -notmatch '/' -and $_.Name -like '*.nuspec' }) | Select-Object -First 1 + if ($null -eq $nuspec) { throw "$Path contains no .nuspec." } + $reader = [System.IO.StreamReader]::new($nuspec.Open()) + try { [xml] $manifest = $reader.ReadToEnd() } + finally { $reader.Dispose() } + $bridgeEntry = $zip.GetEntry('build/native/cheatengine-sdk-lua-bridge.dll') + if ($null -eq $bridgeEntry) { throw "$Path contains no build/native/cheatengine-sdk-lua-bridge.dll." } + $null = New-Item -ItemType Directory -Force -Path $ExtractTo + $bridgePath = Join-Path $ExtractTo 'cheatengine-sdk-lua-bridge.dll' + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($bridgeEntry, $bridgePath, $true) + } + finally { + $zip.Dispose() + } + + return [ordered]@{ + id = $manifest.package.metadata.id + version = $manifest.package.metadata.version + nupkgSha256 = Get-QualificationFileSha256 -Path $Path + bridgePath = $bridgePath + bridgeSha256 = Get-QualificationFileSha256 -Path $bridgePath + } +} + +function Get-BridgeFingerprint { + <# + .SYNOPSIS + The source fingerprint the bridge exports (the same read as the CI native job). + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $BridgePath) + + $module = [System.Runtime.InteropServices.NativeLibrary]::Load($BridgePath) + try { + $address = [System.Runtime.InteropServices.NativeLibrary]::GetExport($module, 'cheatengine_sdk_lua_bridge_source_fingerprint') + return [System.Runtime.InteropServices.Marshal]::PtrToStringAnsi($address) + } + finally { + [System.Runtime.InteropServices.NativeLibrary]::Free($module) + } +} + +function Build-Harness { + <# + .SYNOPSIS + Stage 5: builds one harness from the repository sources against the exact package in a throw-away consumer + project with an isolated NuGet packages folder, publishes it into its bundle folder and checks its closure. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $RunDirectory, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Package, + [Parameter(Mandatory)] [string] $FeedDirectory + ) + + $definition = $Harnesses[$Name] + $buildDirectory = Join-Path $RunDirectory "build\$Name" + $bundleDirectory = Join-Path $RunDirectory "bundles\$Name" + $packagesDirectory = Join-Path $RunDirectory 'nuget-packages' + if (-not $PSCmdlet.ShouldProcess($bundleDirectory, "build harness $Name from $($Package.id) $($Package.version)")) { return $null } + + $null = New-Item -ItemType Directory -Force -Path $buildDirectory, $bundleDirectory, $packagesDirectory + foreach ($stub in 'Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props') { + Set-Content -LiteralPath (Join-Path $buildDirectory $stub) -Value '' -Encoding utf8 + } + Copy-Item -LiteralPath (Join-Path $RepositoryRoot 'global.json') -Destination (Join-Path $buildDirectory 'global.json') + + $compile = foreach ($source in $definition.Sources) { + $full = Join-Path $RepositoryRoot $source + $pattern = if (Test-Path -LiteralPath $full -PathType Container) { Join-Path $full '*.cs' } else { $full } + " " + } + $constants = if ($definition.Constants) { " `$(DefineConstants);$($definition.Constants)`n" } else { '' } + $project = @" + + + net10.0 + 14.0 + enable + enable + $($definition.Assembly) + $($definition.Namespace) + x64 + x64 + true + true + false + false + $($definition.GenerateEntryPoint) +$constants + + +$($compile -join "`n") + + +"@ + $projectPath = Join-Path $buildDirectory "$($definition.Assembly).csproj" + Set-Content -LiteralPath $projectPath -Value $project -Encoding utf8 + $packageId = [System.Security.SecurityElement]::Escape($Package.id) + Set-Content -LiteralPath (Join-Path $buildDirectory 'NuGet.Config') -Encoding utf8 -Value @" + + + + + + + + + + + + + +"@ + + $previousPackages = $env:NUGET_PACKAGES + $env:NUGET_PACKAGES = $packagesDirectory + Push-Location $buildDirectory + try { + $sdk = (Invoke-Native -FilePath 'dotnet' -ArgumentList @('--version') | Select-Object -First 1).Trim() + $pinned = (Get-Content -LiteralPath (Join-Path $RepositoryRoot 'global.json') -Raw | ConvertFrom-Json).sdk.version + if ($sdk -ne $pinned) { throw "The bundle build directory resolves SDK $sdk, global.json pins $pinned." } + $null = Invoke-Native -FilePath 'dotnet' -ArgumentList @('restore', $projectPath, '--configfile', (Join-Path $buildDirectory 'NuGet.Config'), '--packages', $packagesDirectory, '--no-http-cache', '--force-evaluate', '--nologo') + $buildOutput = Invoke-Native -FilePath 'dotnet' -ArgumentList @('publish', $projectPath, '-c', 'Release', '--no-restore', '--nologo', '-o', $bundleDirectory) + } + finally { + Pop-Location + $env:NUGET_PACKAGES = $previousPackages + } + + $problems = @(Test-QualificationBundleClosure -BundleDirectory $bundleDirectory -PluginFileName "$($definition.Assembly).dll" -PackagedBridgeSha256 $Package.bridgeSha256) + if ($problems.Count -gt 0) { throw "Bundle $Name is not closed over the package: $($problems -join ' ')" } + + $contentHashFile = Join-Path $packagesDirectory ("$($Package.id)/$($Package.version)/$($Package.id).$($Package.version).nupkg.sha512".ToLowerInvariant()) + return [ordered]@{ + name = $Name + directory = $bundleDirectory + plugin = Join-Path $bundleDirectory "$($definition.Assembly).dll" + warnings = @($buildOutput | Where-Object { $_ -match ': warning ' } | Select-Object -Unique) + contentHash = if (Test-Path -LiteralPath $contentHashFile) { (Get-Content -LiteralPath $contentHashFile -Raw).Trim() } else { $null } + } +} + +function Join-SharedCoexistenceBundle { + <# + .SYNOPSIS + Q09.a: copies the A and B bundles into one folder and refuses a same-named file with different bytes. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $RunDirectory) + + $shared = Join-Path $RunDirectory 'bundles\CoexistenceShared' + if (-not $PSCmdlet.ShouldProcess($shared, 'merge the Coexistence A and B bundles')) { return $shared } + $null = New-Item -ItemType Directory -Force -Path $shared + foreach ($source in 'CoexistenceA', 'CoexistenceB') { + $sourceDirectory = Join-Path $RunDirectory "bundles\$source" + foreach ($file in Get-ChildItem -LiteralPath $sourceDirectory -Recurse -File) { + $relative = [System.IO.Path]::GetRelativePath($sourceDirectory, $file.FullName) + $destination = Join-Path $shared $relative + if (Test-Path -LiteralPath $destination) { + if ((Get-QualificationFileSha256 -Path $destination) -ne (Get-QualificationFileSha256 -Path $file.FullName)) { + throw "The shared folder would hold two different '$relative'." + } + continue + } + $null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) + Copy-Item -LiteralPath $file.FullName -Destination $destination + } + } + + return $shared +} + +function Copy-Sandbox { + <# + .SYNOPSIS + Stage 4: mirrors the installation into the sandbox (robocopy /MIR) and compares every file by SHA-256. + #> + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Mandatory)] [string] $Sandbox) + + if (-not $PSCmdlet.ShouldProcess($Sandbox, "mirror $CheatEnginePath")) { return } + $null = New-Item -ItemType Directory -Force -Path $Sandbox + $null = Invoke-Native -FilePath 'robocopy.exe' -ArgumentList @($CheatEnginePath, $Sandbox, '/MIR', '/R:1', '/W:1', '/NFL', '/NDL', '/NP', '/NJH', '/NJS') -FailureThreshold 8 + $problems = [System.Collections.Generic.List[string]]::new() + $sourceRoot = [System.IO.Path]::GetFullPath($CheatEnginePath) + foreach ($file in Get-ChildItem -LiteralPath $CheatEnginePath -Recurse -File) { + $relative = [System.IO.Path]::GetRelativePath($sourceRoot, $file.FullName) + $copy = Join-Path $Sandbox $relative + if (-not (Test-Path -LiteralPath $copy -PathType Leaf)) { $problems.Add("missing $relative"); continue } + if ((Get-QualificationFileSha256 -Path $copy) -ne (Get-QualificationFileSha256 -Path $file.FullName)) { $problems.Add("differs $relative") } + } + + if ($problems.Count -gt 0) { Exit-Qualification -Code 5 -Reason ('The sandbox is not an exact copy: ' + ($problems -join ', ')) } +} + +function Publish-QualificationTarget { + <# + .SYNOPSIS + Stage 7: publishes the Native AOT qualification target for one architecture into the run directory. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $RepositoryRoot, [Parameter(Mandatory)] [string] $RunDirectory, [Parameter(Mandatory)] [ValidateSet('x64', 'x86')] [string] $Architecture) + + $output = Join-Path $RunDirectory "target-$Architecture" + if ($PSCmdlet.ShouldProcess($output, "publish the qualification target for win-$Architecture")) { + $null = Invoke-Native -FilePath 'dotnet' -ArgumentList @('publish', (Join-Path $RepositoryRoot 'tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj'), '-c', 'Release', '-r', "win-$Architecture", '-o', $output, '--nologo') + } + + return Join-Path $output 'CheatEngine.SDK.QualificationTarget.exe' +} + +function Start-QualificationTarget { + <# + .SYNOPSIS + Starts the qualification target and reads its one-line ready record. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param([Parameter(Mandatory)] [string] $Executable) + + if (-not $PSCmdlet.ShouldProcess($Executable, 'start the qualification target')) { return $null } + $start = [System.Diagnostics.ProcessStartInfo]::new($Executable) + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.WorkingDirectory = Split-Path -Parent $Executable + $process = [System.Diagnostics.Process]::Start($start) + $readLine = $process.StandardOutput.ReadLineAsync() + if (-not $readLine.Wait([System.TimeSpan]::FromSeconds(30))) { throw 'The qualification target printed no ready record within 30 seconds.' } + $ready = $readLine.Result | ConvertFrom-Json + return [ordered]@{ process = $process; ready = $ready; sha256 = Get-QualificationFileSha256 -Path $Executable } +} + +function Stop-QualificationTarget { + [CmdletBinding(SupportsShouldProcess)] + param([AllowNull()] [System.Collections.IDictionary] $Target) + + if ($null -eq $Target -or $Target.process.HasExited) { return } + if (-not $PSCmdlet.ShouldProcess("target $($Target.process.Id)", 'stop')) { return } + try { + $Target.process.StandardInput.WriteLine('exit') + if (-not $Target.process.WaitForExit(5000)) { $Target.process.Kill() } + } + catch [System.InvalidOperationException] { + Write-Verbose "The target already exited: $($_.Exception.Message)" + } +} + +function Export-CheatEngineRegistry { + <# + .SYNOPSIS + reg export of HKCU\Software\Cheat Engine; returns $false when the key does not exist. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] [string] $Path) + + $null = & reg.exe query $RegistryKey 2>&1 + if ($LASTEXITCODE -ne 0) { return $false } + $null = Invoke-Native -FilePath 'reg.exe' -ArgumentList @('export', $RegistryKey, $Path, '/y') + return $true +} + +function Restore-CheatEngineRegistry { + <# + .SYNOPSIS + Stage 12: restores the exported key (or removes a key the run created) and verifies the result. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([bool])] + param([Parameter(Mandatory)] [string] $Before, [Parameter(Mandatory)] [bool] $ExistedBefore, [Parameter(Mandatory)] [string] $Verify) + + if (-not $PSCmdlet.ShouldProcess($RegistryKey, 'restore the exported key')) { return $false } + $null = & reg.exe delete $RegistryKey /f 2>&1 + if ($ExistedBefore) { $null = Invoke-Native -FilePath 'reg.exe' -ArgumentList @('import', $Before) } + $existsAfter = Export-CheatEngineRegistry -Path $Verify + if (-not $ExistedBefore) { return -not $existsAfter } + if (-not $existsAfter) { return $false } + $diff = Compare-RegistrySnapshot -Before (Read-RegistryExport -Path $Before) -After (Read-RegistryExport -Path $Verify) -RootKey 'HKEY_CURRENT_USER\Software\Cheat Engine' + return ($diff.added + $diff.removed + $diff.changed) -eq 0 +} + +function Read-DriverEvent { + <# + .SYNOPSIS + Reads the event lines the driver appended since the given offset. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param([Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [long] $Offset) + + $lines = [System.Collections.Generic.List[object]]::new() + if (-not (Test-Path -LiteralPath $Path)) { return [ordered]@{ offset = $Offset; lines = $lines } } + $stream = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + $null = $stream.Seek($Offset, [System.IO.SeekOrigin]::Begin) + $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8) + $text = $reader.ReadToEnd() + $complete = $text.LastIndexOf("`n") + if ($complete -ge 0) { + foreach ($line in $text.Substring(0, $complete).Split("`n")) { + if ($line.Trim()) { $lines.Add(($line.Trim() | ConvertFrom-Json -Depth 64)) } + } + $Offset += [System.Text.Encoding]::UTF8.GetByteCount($text.Substring(0, $complete + 1)) + } + } + finally { + $stream.Dispose() + } + + return [ordered]@{ offset = $Offset; lines = $lines } +} + +function Invoke-CheatEngineSession { + <# + .SYNOPSIS + Stages 9-10: launches the sandbox Cheat Engine with the generated driver, serves the operator steps and + enforces the watchdog. Returns the raw driver events and the session facts. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] [string] $Sandbox, + [Parameter(Mandatory)] [string] $SessionDirectory, + [Parameter(Mandatory)] [object] $Definition, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Bundles, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Targets, + [AllowNull()] [string] $FaultFile + ) + + $eventsPath = Join-Path $SessionDirectory 'driver-events.jsonl' + $handshakeDirectory = Join-Path $SessionDirectory 'handshake' + $null = New-Item -ItemType Directory -Force -Path $handshakeDirectory + $template = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'driver\zz_cesdk_qualification.template.lua') -Raw + $driver = Expand-QualificationDriver -Template $template -Values ([ordered]@{ + RUN_ID = Split-Path -Leaf $SessionDirectory + EVENTS_PATH = $eventsPath.Replace('\', '/') + PROGRESS_PATH = (Join-Path $SessionDirectory 'progress.txt').Replace('\', '/') + HANDSHAKE_DIR = $handshakeDirectory.Replace('\', '/') + DONE_PATH = (Join-Path $SessionDirectory 'done.txt').Replace('\', '/') + STEPS = @($Definition.steps) + BUNDLES = $Bundles + TARGETS = $Targets + }) + $driverPath = Join-Path $Sandbox "autorun\$DriverFileName" + $session = [ordered]@{ events = [System.Collections.Generic.List[object]]::new(); answers = [ordered]@{}; exitCode = $null; killed = $false; ceStartMs = 0; driverSha256 = $null } + if (-not $PSCmdlet.ShouldProcess($driverPath, 'install the driver and start Cheat Engine')) { return $session } + + [System.IO.File]::WriteAllText($driverPath, $driver, [System.Text.UTF8Encoding]::new($false)) + $session.driverSha256 = Get-QualificationFileSha256 -Path $driverPath + $start = [System.Diagnostics.ProcessStartInfo]::new((Join-Path $Sandbox 'cheatengine-x86_64.exe')) + $start.UseShellExecute = $false + $start.WorkingDirectory = $Sandbox + $clock = [System.Diagnostics.Stopwatch]::StartNew() + $ce = [System.Diagnostics.Process]::Start($start) + $offset = 0L + try { + while (-not $ce.HasExited) { + if ($clock.Elapsed.TotalSeconds -gt $CeTimeoutSeconds) { + $ce.Kill($true) + $session.killed = $true + break + } + $read = Read-DriverEvent -Path $eventsPath -Offset $offset + $offset = $read.offset + foreach ($line in $read.lines) { + $session.events.Add($line) + if ($line.kind -eq 'AwaitOperator') { Invoke-OperatorStep -Request ($line.message | ConvertFrom-Json) -Definition $Definition -HandshakeDirectory $handshakeDirectory -Answers $session.answers -FaultFile $FaultFile -Events $session.events } + elseif ($line.kind -eq 'StepResult') { Write-Information " step $($line.message)" } + } + Start-Sleep -Milliseconds 200 + } + $null = $ce.WaitForExit(15000) + $session.exitCode = if ($ce.HasExited) { $ce.ExitCode } else { $null } + foreach ($line in (Read-DriverEvent -Path $eventsPath -Offset $offset).lines) { $session.events.Add($line) } + } + finally { + if (-not $ce.HasExited) { $ce.Kill($true) } + if (Test-Path -LiteralPath $driverPath) { Remove-Item -LiteralPath $driverPath -Force } + } + + return $session +} + +function Invoke-OperatorStep { + <# + .SYNOPSIS + Shows an operator instruction, records the answer and releases the driver through its handshake file. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Request, + [Parameter(Mandatory)] [object] $Definition, + [Parameter(Mandatory)] [string] $HandshakeDirectory, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Answers, + [AllowNull()] [string] $FaultFile, + [Parameter(Mandatory)] [System.Collections.Generic.List[object]] $Events + ) + + $step = @($Definition.steps)[$Request.step - 1] + if ((Test-HasProperty -InputObject $step -Name 'removeFaultFile') -and $step.removeFaultFile -and $FaultFile -and (Test-Path -LiteralPath $FaultFile)) { + Remove-Item -LiteralPath $FaultFile -Force + $Events.Add([pscustomobject]@{ tMs = -1; source = 'Runner'; kind = 'FaultFileRemoved'; message = 'liveprobe.fault.json removed before the operator step.' }) + } + + Write-Information '' + Write-Information "OPERATOR STEP $($Request.step) ($($Request.id)): $($Request.instruction)" + $answer = Read-Host -Prompt 'Your observation (Enter to continue)' + $Answers[[string] $Request.id] = $answer + [System.IO.File]::WriteAllText((Join-Path $HandshakeDirectory "step-$($Request.step).txt"), $answer, [System.Text.UTF8Encoding]::new($false)) +} + +# ---------------------------------------------------------------------------------------------------- main +$RepositoryRoot = Get-RepositoryRoot +if (-not $CheatEnginePath) { $CheatEnginePath = Join-Path $env:ProgramFiles 'Cheat Engine' } +if (-not $WorkRoot) { $WorkRoot = Join-Path $env:LOCALAPPDATA 'CheatEngineNet\qualification' } +$supportProfile = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'docs/qualification/support-profile.json') -Raw | ConvertFrom-Json -Depth 64 +$qualifiable = @($supportProfile.profiles | Where-Object { $_.id -eq $ProfileId }) | Select-Object -First 1 +$matrix = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'docs/qualification/matrix.json') -Raw | ConvertFrom-Json -Depth 64 +$plan = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'scenarios.json') -Raw | ConvertFrom-Json -Depth 64 + +$workRootProblem = Test-QualificationWorkRoot -WorkRoot $WorkRoot -RepositoryRoot $RepositoryRoot +if ($workRootProblem) { Exit-Qualification -Code 5 -Reason $workRootProblem } + +$selected = [System.Collections.Generic.List[object]]::new() +foreach ($id in $Scenario) { + if ($id -eq 'CheckpointB') { + foreach ($definition in $plan.scenarios) { if ($definition.support -notin 'Manual', 'NotApplicable') { $selected.Add($definition) } } + continue + } + $definition = @($plan.scenarios | Where-Object { $_.id -ceq $id }) | Select-Object -First 1 + if ($null -eq $definition) { Exit-Qualification -Code 2 -Reason "Unknown scenario '$id'." } + if ($definition.support -in 'Manual', 'NotApplicable') { Exit-Qualification -Code 2 -Reason "Scenario $id is $($definition.support): $($definition.reason)" } + $selected.Add($definition) +} + +if (-not $PreflightOnly) { + if (-not $PackagePath -or -not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { Exit-Qualification -Code 2 -Reason '-PackagePath must name the exact CheatEngine.SDK .nupkg.' } + if (-not $Smoke) { + if ($PackageSource -eq 'CiArtifact' -and $CiRunUrl -notmatch '^https://github\.com/CheatEngineNet/CheatEngine\.SDK/actions/runs/\d+(/attempts/\d+)?$') { Exit-Qualification -Code 2 -Reason '-CiRunUrl must name the CI run that produced the artifact.' } + if ($Operator -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$') { Exit-Qualification -Code 2 -Reason '-Operator must be the GitHub handle recorded in the receipts.' } + if (($PullRequest -gt 0) -xor ($HeadSha -match '^[0-9a-f]{40}$')) { Exit-Qualification -Code 2 -Reason '-PullRequest and -HeadSha go together.' } + } +} + +# Stage 2, preflight (read-only). +$facts = Invoke-Preflight -RepositoryRoot $RepositoryRoot -SupportProfile $qualifiable +Write-Information ($facts | ConvertTo-Json -Depth 8) +if ($PreflightOnly) { + if (-not $facts.matchesProfile) { Exit-Qualification -Code 4 -Reason ('The installation is not the profiled host: ' + ($facts.mismatches -join '; ')) } + Write-Information 'Preflight only: the installation matches the support profile; nothing was started or written.' + exit 0 +} + +Test-PreflightSafety -Facts $facts -AllowOthers:$AllowOtherCheatEngineInstances -AllowLoad:$AllowConcurrentLoad -AllowDirty:$Smoke +if (-not $PSCmdlet.ShouldProcess("Cheat Engine sandbox under $WorkRoot", "run $($selected.Count) scenario(s): $(@($selected | ForEach-Object id) -join ', ')")) { exit 0 } + +# Stage 3, mutex. +$createdNew = $false +$mutex = [System.Threading.Mutex]::new($false, 'Global\ce-lab', [ref] $createdNew) +$acquired = $false +try { + try { $acquired = $mutex.WaitOne([System.TimeSpan]::FromMinutes($MutexTimeoutMinutes)) } + catch [System.Threading.AbandonedMutexException] { $acquired = $true } + if (-not $acquired) { Exit-Qualification -Code 5 -Reason "Global\ce-lab was not released within $MutexTimeoutMinutes minutes." } + Write-Information "Global\ce-lab acquired (created by this run: $createdNew)." + + $runId = [System.DateTimeOffset]::UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'", [System.Globalization.CultureInfo]::InvariantCulture) + $runDirectory = Join-Path $WorkRoot "runs\$runId" + $sandbox = Join-Path $WorkRoot 'sandbox' + $null = New-Item -ItemType Directory -Force -Path $runDirectory + Write-Information "Run $runId in $runDirectory" + + # Stage 4, sandbox. + Copy-Sandbox -Sandbox $sandbox + $staleDriver = Join-Path $sandbox "autorun\$DriverFileName" + if (Test-Path -LiteralPath $staleDriver) { Remove-Item -LiteralPath $staleDriver -Force } + if (@(Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith($sandbox, [System.StringComparison]::OrdinalIgnoreCase) }).Count -gt 0) { Exit-Qualification -Code 5 -Reason 'A process from the sandbox is already running.' } + + # Stage 5-6, package, bundles, bridge identity. + $feed = Join-Path $runDirectory 'package\feed' + $null = New-Item -ItemType Directory -Force -Path $feed + Copy-Item -LiteralPath $PackagePath -Destination $feed + $package = Get-PackageIdentity -Path $PackagePath -ExtractTo (Join-Path $runDirectory 'package') + $fingerprint = Get-BridgeFingerprint -BridgePath $package.bridgePath + $bundles = [ordered]@{} + $needed = @($selected | ForEach-Object { @($_.harnesses) } | Select-Object -Unique) + if ($needed -contains 'CoexistenceShared') { $needed = @($needed | Where-Object { $_ -ne 'CoexistenceShared' }) + @('CoexistenceA', 'CoexistenceB') | Select-Object -Unique } + foreach ($name in $needed) { $bundles[$name] = Build-Harness -Name $name -RepositoryRoot $RepositoryRoot -RunDirectory $runDirectory -Package $package -FeedDirectory $feed } + $package.contentHashSha512 = @($bundles.Values | ForEach-Object contentHash | Where-Object { $_ }) | Select-Object -First 1 + $driverBundles = [ordered]@{} + foreach ($name in $bundles.Keys) { $driverBundles[$name] = $bundles[$name].plugin.Replace('\', '/') } + if ($selected | Where-Object { @($_.harnesses) -contains 'CoexistenceShared' }) { + $shared = Join-SharedCoexistenceBundle -RunDirectory $runDirectory + $driverBundles.CoexistenceSharedA = (Join-Path $shared 'CheatEngine.SDK.LivePlugin.Coexistence.PluginA.dll').Replace('\', '/') + $driverBundles.CoexistenceSharedB = (Join-Path $shared 'CheatEngine.SDK.LivePlugin.Coexistence.PluginB.dll').Replace('\', '/') + $bundles.CoexistenceShared = [ordered]@{ name = 'CoexistenceShared'; directory = $shared; warnings = @() } + } + foreach ($bundle in $bundles.Values) { + $manifest = [ordered]@{ schema = 'cheatengine-qualification-bundle/v0'; name = $bundle.name; package = "$($package.id) $($package.version)"; nupkgSha256 = $package.nupkgSha256; warnings = @($bundle.warnings); files = @(Get-BundleFileManifest -BundleDirectory $bundle.directory) } + $manifestText = ConvertTo-QualificationJson -InputObject $manifest + [System.IO.File]::WriteAllText((Join-Path $runDirectory "bundle-manifest.$($bundle.name).json"), $manifestText, [System.Text.UTF8Encoding]::new($false)) + $bundle.manifestSha256 = Get-QualificationTextSha256 -Text $manifestText + $bundle.files = $manifest.files + } + + $targetExecutables = [ordered]@{} + foreach ($architecture in @($selected | ForEach-Object target | Where-Object { $_ -in 'x64', 'x86' } | Select-Object -Unique)) { + $targetExecutables[$architecture] = Publish-QualificationTarget -RepositoryRoot $RepositoryRoot -RunDirectory $runDirectory -Architecture $architecture + } + + # The builds above leave compiler and MSBuild servers behind; stop them so they do not load the machine during the runs. + $null = Invoke-Native -FilePath 'dotnet' -ArgumentList @('build-server', 'shutdown') + + $tree = (Invoke-Native -FilePath 'git' -ArgumentList @('-C', $RepositoryRoot, 'rev-parse', 'HEAD^{tree}') | Select-Object -First 1).Trim() + $commit = (Invoke-Native -FilePath 'git' -ArgumentList @('-C', $RepositoryRoot, 'rev-parse', 'HEAD') | Select-Object -First 1).Trim() + $scriptSha256 = Get-QualificationTextSha256 -Text ([System.IO.File]::ReadAllText($PSCommandPath)) + $outcomes = [System.Collections.Generic.List[object]]::new() + $exitCode = 0 + + foreach ($definition in $selected) { + $sessionDirectory = Join-Path $runDirectory "sessions\$($definition.id)" + $null = New-Item -ItemType Directory -Force -Path $sessionDirectory + $runnerEvents = [System.Collections.Generic.List[object]]::new() + $started = [System.DateTimeOffset]::UtcNow + $sessionClock = [System.Diagnostics.Stopwatch]::StartNew() + $target = $null + $manifestPath = $null + $faultFile = $null + $registryBefore = Join-Path $sessionDirectory 'hkcu-before.reg' + $registryAfter = Join-Path $sessionDirectory 'hkcu-after.reg' + $session = $null + $registry = $null + Write-Information "== $($definition.id) ($($definition.level))" + try { + # Stage 7, targets and authorization. + $driverTargets = [ordered]@{} + if ($definition.target -in 'x64', 'x86') { + $target = Start-QualificationTarget -Executable $targetExecutables[$definition.target] + $driverTargets[$definition.target] = [long] $target.ready.pid + $runnerEvents.Add([pscustomobject]@{ tMs = $sessionClock.ElapsedMilliseconds; source = 'Runner'; kind = 'TargetReady'; message = ($target.ready | ConvertTo-Json -Compress -Depth 8) }) + } + if (@($definition.harnesses) | Where-Object { $_ -like 'LiveProbe*' }) { + $manifestPath = Join-Path $sessionDirectory 'live-probe-authorization.json' + $authorization = [ordered]@{ schema = 'ce77-live-probe-v1'; acknowledgement = $LiveProbeAcknowledgement; hostSha256 = $facts.ceExeSha256.ToUpperInvariant(); targetProcessId = [int] $target.ready.pid; targetSha256 = $target.sha256.ToUpperInvariant(); disposable = $true; expiresUtc = [System.DateTimeOffset]::UtcNow.AddMinutes(30).ToString('o') } + [System.IO.File]::WriteAllText($manifestPath, ($authorization | ConvertTo-Json), [System.Text.UTF8Encoding]::new($false)) + $env:CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT = $LiveProbeAcknowledgement + $env:CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE = $manifestPath + } + if ((Test-HasProperty -InputObject $definition -Name 'faultStage') -and $definition.faultStage) { + $faultFile = Join-Path $bundles[@($definition.harnesses)[0]].directory 'liveprobe.fault.json' + [System.IO.File]::WriteAllText($faultFile, (@{ schema = 'ce77-live-probe-fault-v1'; throwIn = $definition.faultStage } | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false)) + $runnerEvents.Add([pscustomobject]@{ tMs = $sessionClock.ElapsedMilliseconds; source = 'Runner'; kind = 'FaultFileWritten'; message = "throwIn $($definition.faultStage)" }) + } + + # Stage 8, HKCU before. + $existedBefore = Export-CheatEngineRegistry -Path $registryBefore + if (-not $existedBefore) { [System.IO.File]::WriteAllText($registryBefore, '', [System.Text.Encoding]::Unicode) } + + # Stages 9-10, driver and launch. + $ceStart = $sessionClock.ElapsedMilliseconds + $session = Invoke-CheatEngineSession -Sandbox $sandbox -SessionDirectory $sessionDirectory -Definition $definition -Bundles $driverBundles -Targets $driverTargets -FaultFile $faultFile + $session.ceStartMs = $ceStart + $runnerEvents.Add([pscustomobject]@{ tMs = $sessionClock.ElapsedMilliseconds; source = 'Runner'; kind = 'CheatEngineExited'; message = "exit code $($session.exitCode); killed by watchdog: $($session.killed)" }) + } + finally { + # Stage 11, cleanup. + Stop-QualificationTarget -Target $target + foreach ($stray in @(Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith($sandbox, [System.StringComparison]::OrdinalIgnoreCase) })) { Stop-Process -Id $stray.Id -Force } + foreach ($file in @($manifestPath, $faultFile)) { if ($file -and (Test-Path -LiteralPath $file)) { Remove-Item -LiteralPath $file -Force } } + Remove-Item Env:CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT -ErrorAction SilentlyContinue + Remove-Item Env:CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE -ErrorAction SilentlyContinue + + # Stage 12, HKCU after, compare, restore only a non-empty difference. + $existsAfter = Export-CheatEngineRegistry -Path $registryAfter + if (-not $existsAfter) { [System.IO.File]::WriteAllText($registryAfter, '', [System.Text.Encoding]::Unicode) } + $diff = Compare-RegistrySnapshot -Before (Read-RegistryExport -Path $registryBefore) -After (Read-RegistryExport -Path $registryAfter) -RootKey 'HKEY_CURRENT_USER\Software\Cheat Engine' + $registry = [ordered]@{ key = $RegistryKey; exportBeforeSha256 = Get-QualificationFileSha256 -Path $registryBefore; exportAfterSha256 = Get-QualificationFileSha256 -Path $registryAfter; restored = $false; diff = $diff } + if (($diff.added + $diff.removed + $diff.changed) -gt 0) { + if ((Get-CheatEngineProcess).Count -gt 0) { + Exit-Qualification -Code 7 -Reason "HKCU changed ($($diff.valueNames -join ', ')) while another Cheat Engine instance runs; restore it by hand after closing it: reg delete `"$RegistryKey`" /f; reg import `"$registryBefore`"" + } + $registry.restored = Restore-CheatEngineRegistry -Before $registryBefore -ExistedBefore $existedBefore -Verify (Join-Path $sessionDirectory 'hkcu-restored.reg') + if (-not $registry.restored) { Exit-Qualification -Code 7 -Reason "HKCU restore could not be verified. Backup: $registryBefore; command: reg delete `"$RegistryKey`" /f; reg import `"$registryBefore`"" } + } + } + + # Stage 13, redaction, outcome, receipt. + $redactionPaths = [ordered]@{ '' = $sandbox; '' = $WorkRoot; '' = $RepositoryRoot; '' = $CheatEnginePath } + foreach ($name in $bundles.Keys) { $redactionPaths[""] = $bundles[$name].directory } + foreach ($architecture in $targetExecutables.Keys) { $redactionPaths[""] = Split-Path -Parent $targetExecutables[$architecture] } + $map = Get-RedactionMap -Paths $redactionPaths + $raw = @($runnerEvents) + @($session.events) + $redacted = ConvertTo-RedactedSessionEvent -Raw $raw -Map $map -OffsetMs $session.ceStartMs + $outcome = Resolve-QualificationOutcome -Scenario $definition -Steps $redacted.steps -Answers $session.answers -PackagedBridgeSha256 $package.bridgeSha256 + if ($session.killed -or $null -eq $session.exitCode) { + $outcome.status = 'Failed' + $outcome.passKind = $null + $outcome.justification = "Cheat Engine did not finish the driver within $CeTimeoutSeconds seconds and was stopped by the watchdog." + $exitCode = 6 + } + Write-Information " $($definition.id): $($outcome.status) $($outcome.passKind) $($outcome.justification)" + $finished = [System.DateTimeOffset]::UtcNow + $row = @($matrix.rows | Where-Object { $_.id -ceq $definition.id }) | Select-Object -First 1 + $limited = Limit-QualificationEventLog -Events $redacted.entries + $outcomes.Add([ordered]@{ id = $definition.id; status = $outcome.status; passKind = $outcome.passKind; events = $redacted.entries.Count; registryDiff = $registry.diff; restored = $registry.restored; ceExitCode = $session.exitCode; killed = $session.killed; unredactedUserPath = [bool] (($limited.events | ConvertTo-Json -Depth 8) -match '(?i)[A-Za-z]:(\\\\|\\|/)Users') }) + if ($Smoke -or $outcome.status -eq 'Inconclusive') { continue } + + $receiptId = Get-QualificationReceiptId -StartedUtc $started -QualificationId $definition.id -NupkgSha256 $package.nupkgSha256 + $eventLog = [ordered]@{ schema = 'cheatengine-qualification-events/v0'; receiptId = $receiptId; events = @($limited.events) } + if ($null -ne $limited.summarized) { $eventLog.summarized = $limited.summarized } + $eventText = ConvertTo-QualificationJson -InputObject $eventLog + $targetRecord = if ($null -ne $target) { [ordered]@{ kind = 'QualificationTarget'; arch = $definition.target; sha256 = $target.sha256 } } else { [ordered]@{ kind = 'None'; arch = $null; sha256 = $null } } + $usedBundles = foreach ($name in @($definition.harnesses)) { [ordered]@{ name = $name; manifestSha256 = $bundles[$name].manifestSha256; files = @($bundles[$name].files) } } + $receipt = ConvertTo-QualificationReceipt -Context ([ordered]@{ + QualificationId = $definition.id; Level = $definition.level; ProfileId = $ProfileId; Operator = $Operator; LoadRoute = $definition.loadRoute + StartedUtc = $started; FinishedUtc = $finished; CreatedUtc = [System.DateTimeOffset]::UtcNow; CeStartMs = $session.ceStartMs; Indicative = [bool] $AllowConcurrentLoad + Repository = [ordered]@{ name = 'CheatEngineNet/CheatEngine.SDK'; treeHash = $tree; commit = $commit; pullRequest = $(if ($PullRequest -gt 0) { [ordered]@{ number = $PullRequest; headSha = $HeadSha } } else { $null }) } + Runner = [ordered]@{ script = 'eng/qualification/Invoke-LocalQualification.ps1'; scriptSha256 = $scriptSha256; sourceRepository = 'CheatEngineNet/CheatEngine.SDK'; sourceCommit = $commit; mutex = 'Global\ce-lab' } + Package = [ordered]@{ id = $package.id; version = $package.version; nupkgSha256 = $package.nupkgSha256; contentHashSha512 = $package.contentHashSha512; source = $PackageSource; ciRunUrl = $(if ($PackageSource -eq 'CiArtifact') { $CiRunUrl } else { $null }) } + Host = [ordered]@{ ceExeName = $facts.ceExeName; ceExeSha256 = Get-QualificationFileSha256 -Path (Join-Path $sandbox $facts.ceExeName); ceFileVersion = $facts.ceFileVersion; luaDllSha256 = $facts.luaDllSha256; runtimeconfigSha256 = $facts.runtimeconfigSha256; autorunSha256 = $session.driverSha256; sandboxCopy = $true; osVersion = $facts.osVersion; dotnetRuntimes = @($facts.dotnetRuntimes) } + Bridge = [ordered]@{ sha256 = $package.bridgeSha256; sourceFingerprint = $fingerprint } + Bundles = @($usedBundles); Target = $targetRecord; Registry = $registry + Preconditions = @($row.scenario.preconditions); Operation = $row.scenario.operation; Expected = $row.scenario.expected + Outcome = $outcome; EventLogSha256 = Get-QualificationTextSha256 -Text $eventText; Redactions = @(@($redactionPaths.Keys) + @('', '', '')) + }) + $receiptDirectory = Join-Path $runDirectory "receipts\$($definition.id)" + $null = New-Item -ItemType Directory -Force -Path $receiptDirectory + [System.IO.File]::WriteAllText((Join-Path $receiptDirectory "$receiptId.json"), (ConvertTo-QualificationJson -InputObject $receipt), [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText((Join-Path $receiptDirectory "$receiptId.events.json"), $eventText, [System.Text.UTF8Encoding]::new($false)) + Write-Information " receipt $receiptId" + } + + if ($Smoke) { + $report = [ordered]@{ schema = 'cheatengine-qualification-smoke/v0'; runId = $runId; package = "$($package.id) $($package.version)"; nupkgSha256 = $package.nupkgSha256; bridgeFingerprint = $fingerprint; scenarios = @($outcomes) } + [System.IO.File]::WriteAllText((Join-Path $runDirectory 'smoke-report.json'), (ConvertTo-QualificationJson -InputObject $report), [System.Text.UTF8Encoding]::new($false)) + Write-Information "Smoke report: $(Join-Path $runDirectory 'smoke-report.json') (never a receipt)." + } + elseif ($PublishReceiptsTo -and $PSCmdlet.ShouldProcess($PublishReceiptsTo, 'copy the receipts into docs/qualification/receipts')) { + foreach ($receiptFile in Get-ChildItem -LiteralPath (Join-Path $runDirectory 'receipts') -Recurse -File -Filter '*.json' -ErrorAction SilentlyContinue) { + $destination = Join-Path $PublishReceiptsTo "docs/qualification/receipts/$($receiptFile.Directory.Name)" + $null = New-Item -ItemType Directory -Force -Path $destination + Copy-Item -LiteralPath $receiptFile.FullName -Destination $destination + if ($receiptFile.Name -notlike '*.events.json') { + $published = Get-Content -LiteralPath $receiptFile.FullName -Raw | ConvertFrom-Json + Write-Information "Update matrix.json $($published.qualificationId) $($published.level): status $($published.status), evidence Receipt $($published.receiptId) path docs/qualification/receipts/$($published.qualificationId)/$($receiptFile.Name) sha256 $(Get-QualificationTextSha256 -Text (Get-Content -LiteralPath $receiptFile.FullName -Raw)), treeHash $tree, nupkgSha256 $($package.nupkgSha256)." + } + } + } + + Write-Information ($outcomes | ConvertTo-Json -Depth 6) + exit $exitCode +} +finally { + if ($acquired) { $mutex.ReleaseMutex() } + $mutex.Dispose() +} diff --git a/eng/qualification/QualificationRunner.psm1 b/eng/qualification/QualificationRunner.psm1 new file mode 100644 index 00000000..f3d498ae --- /dev/null +++ b/eng/qualification/QualificationRunner.psm1 @@ -0,0 +1,894 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS + Pure helpers of the local exact-host qualification runner (eng/qualification/Invoke-LocalQualification.ps1). + +.DESCRIPTION + Nothing in this module starts Cheat Engine, touches the registry, takes the Global\ce-lab mutex or reads the Cheat + Engine installation. Functions take text, objects or explicit paths and return values, so the repository tests + (tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs) import this module and + exercise it with synthetic input. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:SdkAssemblies = @( + 'CheatEngine.SDK.Abi.dll' + 'CheatEngine.SDK.Annotations.dll' + 'CheatEngine.SDK.Engine.dll' + 'CheatEngine.SDK.Hosting.dll' + 'CheatEngine.SDK.Lua.dll' + 'CheatEngine.SDK.Lua.Interop.dll' +) +$script:BridgeFileName = 'cheatengine-sdk-lua-bridge.dll' + +function Get-QualificationFileSha256 { + <# + .SYNOPSIS + Lowercase SHA-256 of a file's raw bytes. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $Path) + + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Get-QualificationTextSha256 { + <# + .SYNOPSIS + Lowercase SHA-256 of text after CRLF is normalized to LF, encoded as UTF-8 without BOM (the hash rule of every + committed qualification JSON document). + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Text) + + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text.Replace("`r`n", "`n")) + return [System.Convert]::ToHexStringLower([System.Security.Cryptography.SHA256]::HashData($bytes)) +} + +function ConvertFrom-RegistryExport { + <# + .SYNOPSIS + Parses the text of a reg.exe export into key -> (value name -> raw data). The data stays in memory only, for the + comparison; nothing returned by Compare-RegistrySnapshot contains it. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Text) + + $result = [ordered]@{} + $current = $null + $pending = '' + foreach ($raw in ($Text -split "`r?`n")) { + $line = $raw + if ($pending -ne '') { + $line = $pending + $line.TrimStart() + $pending = '' + } + + if ($line.EndsWith('\') -and -not $line.StartsWith('[')) { + $pending = $line.Substring(0, $line.Length - 1) + continue + } + + if ($line.StartsWith('[') -and $line.EndsWith(']')) { + $current = $line.Substring(1, $line.Length - 2) + $result[$current] = [ordered]@{} + continue + } + + if ($null -eq $current -or $line.Trim() -eq '') { + continue + } + + $match = [regex]::Match($line, '^(@|"(?:[^"\\]|\\.)*")=(.*)$') + if ($match.Success) { + $name = $match.Groups[1].Value + $name = if ($name -eq '@') { '(Default)' } else { $name.Substring(1, $name.Length - 2).Replace('\"', '"').Replace('\\', '\') } + $result[$current][$name] = $match.Groups[2].Value + } + } + + return $result +} + +function Read-RegistryExport { + <# + .SYNOPSIS + Reads a reg.exe export file (UTF-16 LE) and parses it with ConvertFrom-RegistryExport. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param([Parameter(Mandatory)] [string] $Path) + + return ConvertFrom-RegistryExport -Text ([System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::Unicode)) +} + +function Get-RelativeRegistryName { + <# + .SYNOPSIS + A key path relative to the root key, with a trailing backslash ('' for the root itself). + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [string] $Key, [Parameter(Mandatory)] [string] $RootKey) + + if ($Key.Length -le $RootKey.Length) { return '' } + return $Key.Substring($RootKey.Length).TrimStart('\') + '\' +} + +function Compare-RegistrySnapshot { + <# + .SYNOPSIS + Compares two parsed exports. Returns counts and the affected names relative to the root key: a key as + 'Subkey\' and a value as 'Subkey\ValueName'. Registry data is never part of the result. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] [System.Collections.IDictionary] $Before, + [Parameter(Mandatory)] [System.Collections.IDictionary] $After, + [Parameter(Mandatory)] [string] $RootKey + ) + + $added = 0 + $removed = 0 + $changed = 0 + $names = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) + + foreach ($key in $After.Keys) { + if (-not $Before.Contains($key)) { + $added++ + [void] $names.Add((Get-RelativeRegistryName -Key $key -RootKey $RootKey)) + foreach ($value in $After[$key].Keys) { + $added++ + [void] $names.Add((Get-RelativeRegistryName -Key $key -RootKey $RootKey) + $value) + } + } + } + + foreach ($key in $Before.Keys) { + if (-not $After.Contains($key)) { + $removed++ + [void] $names.Add((Get-RelativeRegistryName -Key $key -RootKey $RootKey)) + foreach ($value in $Before[$key].Keys) { + $removed++ + [void] $names.Add((Get-RelativeRegistryName -Key $key -RootKey $RootKey) + $value) + } + continue + } + + $old = $Before[$key] + $new = $After[$key] + foreach ($value in $new.Keys) { + if (-not $old.Contains($value)) { + $added++ + [void] $names.Add((Get-RelativeRegistryName -Key $key -RootKey $RootKey) + $value) + } + elseif ($old[$value] -cne $new[$value]) { + $changed++ + [void] $names.Add((Get-RelativeRegistryName -Key $key -RootKey $RootKey) + $value) + } + } + + foreach ($value in $old.Keys) { + if (-not $new.Contains($value)) { + $removed++ + [void] $names.Add((Get-RelativeRegistryName -Key $key -RootKey $RootKey) + $value) + } + } + } + + return [ordered]@{ + added = $added + removed = $removed + changed = $changed + valueNames = @($names) + } +} + +function Get-RedactionMap { + <# + .SYNOPSIS + Builds the ordered path -> placeholder list the redaction applies, longest path first, plus the user and machine + names. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] [System.Collections.IDictionary] $Paths, + [string] $UserName = [System.Environment]::UserName, + [string] $MachineName = [System.Environment]::MachineName + ) + + $entries = [System.Collections.Generic.List[object]]::new() + foreach ($placeholder in $Paths.Keys) { + $path = [string] $Paths[$placeholder] + if ([string]::IsNullOrWhiteSpace($path)) { continue } + $full = [System.IO.Path]::GetFullPath($path).TrimEnd('\', '/') + $entries.Add([pscustomobject]@{ Path = $full; Placeholder = $placeholder; WholeWord = $false }) + } + + $sorted = @($entries | Sort-Object -Property { $_.Path.Length } -Descending) + $names = @() + if ($UserName.Length -ge 3) { $names += [pscustomobject]@{ Path = $UserName; Placeholder = ''; WholeWord = $true } } + if ($MachineName.Length -ge 3) { $names += [pscustomobject]@{ Path = $MachineName; Placeholder = ''; WholeWord = $true } } + return @($sorted) + $names +} + +function ConvertTo-RedactedText { + <# + .SYNOPSIS + Replaces every mapped path (backslash, forward-slash and JSON-escaped spellings, case-insensitive) with its + placeholder, then the user and machine names, then any remaining user-profile path segment. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [AllowEmptyString()] [string] $Text, + [Parameter(Mandatory)] [object[]] $Map + ) + + $result = $Text + foreach ($entry in $Map) { + $spellings = @($entry.Path, $entry.Path.Replace('\', '/'), $entry.Path.Replace('\', '\\')) | Select-Object -Unique + foreach ($spelling in $spellings) { + $pattern = [regex]::Escape($spelling) + if ($entry.WholeWord) { $pattern = '(?') + return $result +} + +function Limit-QualificationEventLog { + <# + .SYNOPSIS + Keeps the first and last events of a long log and reports how many were dropped from the middle. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Events, + [int] $KeepFirst = 400, + [int] $KeepLast = 100 + ) + + if ($Events.Count -le ($KeepFirst + $KeepLast)) { + return [ordered]@{ events = @($Events); summarized = $null } + } + + $kept = @($Events[0..($KeepFirst - 1)]) + @($Events[($Events.Count - $KeepLast)..($Events.Count - 1)]) + return [ordered]@{ + events = $kept + summarized = [ordered]@{ keptFirst = $KeepFirst; keptLast = $KeepLast; dropped = $Events.Count - $KeepFirst - $KeepLast } + } +} + +function Get-QualificationReceiptId { + <# + .SYNOPSIS + R---, from the run start in UTC. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [datetimeoffset] $StartedUtc, + [Parameter(Mandatory)] [ValidatePattern('^Q(0[1-9]|[1-3][0-9]|4[0-8])(\.[a-z])?$')] [string] $QualificationId, + [Parameter(Mandatory)] [ValidatePattern('^[0-9a-f]{64}$')] [string] $NupkgSha256 + ) + + $stamp = $StartedUtc.ToUniversalTime().ToString("yyyyMMdd'T'HHmmss'Z'", [System.Globalization.CultureInfo]::InvariantCulture) + return "R-$stamp-$QualificationId-$($NupkgSha256.Substring(0, 8))" +} + +function Format-QualificationUtc { + <# + .SYNOPSIS + ISO 8601 UTC with a Z suffix and whole seconds. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [datetimeoffset] $Value) + + return $Value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", [System.Globalization.CultureInfo]::InvariantCulture) +} + +function ConvertTo-QualificationJson { + <# + .SYNOPSIS + Serializes a document with LF newlines and one final newline, the committed form of receipts and event logs. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [AllowNull()] [object] $InputObject) + + $json = ConvertTo-Json -InputObject $InputObject -Depth 64 + return $json.Replace("`r`n", "`n") + "`n" +} + +function ConvertTo-LuaLiteral { + <# + .SYNOPSIS + Renders a value as a Lua literal: strings are quoted and escaped, dictionaries become tables with string keys, + lists become sequences. Used to embed the run's steps, bundles and targets into the autorun driver. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] [object] $InputObject) + + if ($null -eq $InputObject) { return 'nil' } + if ($InputObject -is [bool]) { return $(if ($InputObject) { 'true' } else { 'false' }) } + if ($InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [uint32]) { + return ([long] $InputObject).ToString([System.Globalization.CultureInfo]::InvariantCulture) + } + if ($InputObject -is [double] -or $InputObject -is [decimal]) { + return ([double] $InputObject).ToString('R', [System.Globalization.CultureInfo]::InvariantCulture) + } + if ($InputObject -is [string]) { + $builder = [System.Text.StringBuilder]::new('"') + foreach ($character in $InputObject.ToCharArray()) { + switch ($character) { + '\' { [void] $builder.Append('\\') } + '"' { [void] $builder.Append('\"') } + "`n" { [void] $builder.Append('\n') } + "`r" { [void] $builder.Append('\r') } + "`t" { [void] $builder.Append('\t') } + default { + if ([int] $character -lt 32) { [void] $builder.Append('\' + ([int] $character).ToString('000')) } + else { [void] $builder.Append($character) } + } + } + } + return $builder.Append('"').ToString() + } + if ($InputObject -is [System.Collections.IDictionary] -or $InputObject -is [System.Management.Automation.PSCustomObject]) { + $parts = [System.Collections.Generic.List[string]]::new() + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + $parts.Add('[' + (ConvertTo-LuaLiteral -InputObject ([string] $key)) + '] = ' + (ConvertTo-LuaLiteral -InputObject $InputObject[$key])) + } + } + else { + foreach ($property in $InputObject.PSObject.Properties) { + $parts.Add('[' + (ConvertTo-LuaLiteral -InputObject $property.Name) + '] = ' + (ConvertTo-LuaLiteral -InputObject $property.Value)) + } + } + return '{ ' + ($parts -join ', ') + ' }' + } + if ($InputObject -is [System.Collections.IEnumerable]) { + $parts = foreach ($item in $InputObject) { ConvertTo-LuaLiteral -InputObject $item } + return '{ ' + (@($parts) -join ', ') + ' }' + } + throw "ConvertTo-LuaLiteral cannot render a value of type $($InputObject.GetType().FullName)." +} + +function Expand-QualificationDriver { + <# + .SYNOPSIS + Substitutes the __NAME__ placeholders of the driver template with Lua literals. Every placeholder must be given. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $Template, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Values + ) + + $result = $Template + foreach ($name in $Values.Keys) { + $result = $result.Replace("__$($name)__", (ConvertTo-LuaLiteral -InputObject $Values[$name])) + } + + $left = [regex]::Matches($result, '__[A-Z][A-Z_]*__') + if ($left.Count -gt 0) { + throw "The driver template still contains placeholders: $((@($left | ForEach-Object Value) | Select-Object -Unique) -join ', ')." + } + + return $result +} + +function Get-BundleFileManifest { + <# + .SYNOPSIS + Every file of a bundle with its lowercase SHA-256, bundle-relative forward-slash paths, sorted ordinally. + #> + [CmdletBinding()] + [OutputType([object[]])] + param([Parameter(Mandatory)] [string] $BundleDirectory) + + $root = [System.IO.Path]::GetFullPath($BundleDirectory).TrimEnd('\') + '\' + $files = foreach ($file in Get-ChildItem -LiteralPath $BundleDirectory -Recurse -File) { + [ordered]@{ + path = $file.FullName.Substring($root.Length).Replace('\', '/') + sha256 = Get-QualificationFileSha256 -Path $file.FullName + } + } + + return @(@($files) | Sort-Object -Property { $_.path } -CaseSensitive) +} + +function Test-EntryPointExport { + <# + .SYNOPSIS + True when the assembly declares the public static class CESDK.CESDK with a public static + int CEPluginInitialize(nint, int), read with System.Reflection.Metadata without loading the assembly. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] [string] $AssemblyPath) + + $stream = [System.IO.File]::OpenRead($AssemblyPath) + try { + $pe = [System.Reflection.PortableExecutable.PEReader]::new($stream) + try { + if (-not $pe.HasMetadata) { return $false } + $reader = [System.Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($pe) + foreach ($typeHandle in $reader.TypeDefinitions) { + $type = $reader.GetTypeDefinition($typeHandle) + if ($reader.GetString($type.Namespace) -cne 'CESDK' -or $reader.GetString($type.Name) -cne 'CESDK') { continue } + $typeAttributes = $type.Attributes + $isPublicStatic = (($typeAttributes -band [System.Reflection.TypeAttributes]::VisibilityMask) -eq [System.Reflection.TypeAttributes]::Public) -and + (($typeAttributes -band [System.Reflection.TypeAttributes]::Abstract) -ne 0) -and + (($typeAttributes -band [System.Reflection.TypeAttributes]::Sealed) -ne 0) + if (-not $isPublicStatic) { return $false } + foreach ($methodHandle in $type.GetMethods()) { + $method = $reader.GetMethodDefinition($methodHandle) + if ($reader.GetString($method.Name) -cne 'CEPluginInitialize') { continue } + $attributes = $method.Attributes + $isPublic = ($attributes -band [System.Reflection.MethodAttributes]::MemberAccessMask) -eq [System.Reflection.MethodAttributes]::Public + $isStatic = ($attributes -band [System.Reflection.MethodAttributes]::Static) -ne 0 + $blob = $reader.GetBlobBytes($method.Signature) + # Default calling convention, 2 parameters, returns I4 (0x08), takes I (0x18) and I4 (0x08). + $shape = ($blob.Length -eq 5) -and $blob[0] -eq 0x00 -and $blob[1] -eq 2 -and $blob[2] -eq 0x08 -and $blob[3] -eq 0x18 -and $blob[4] -eq 0x08 + if ($isPublic -and $isStatic -and $shape) { return $true } + } + return $false + } + return $false + } + finally { + $pe.Dispose() + } + } + catch [System.BadImageFormatException] { + return $false + } + finally { + $stream.Dispose() + } +} + +function Test-QualificationBundleClosure { + <# + .SYNOPSIS + Checks that a plugin folder built from the exact package is self-contained: the plugin with the CESDK.CESDK entry + point, the six SDK assemblies, a .deps.json without workspace project entries or absolute paths, the + .runtimeconfig.json, and the native bridge equal to the package's build/native copy. Returns the problems found. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [string] $BundleDirectory, + [Parameter(Mandatory)] [string] $PluginFileName, + [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{64}$')] [string] $PackagedBridgeSha256 + ) + + $problems = [System.Collections.Generic.List[string]]::new() + $plugin = Join-Path $BundleDirectory $PluginFileName + if (-not (Test-Path -LiteralPath $plugin -PathType Leaf)) { + $problems.Add("The plugin $PluginFileName is missing.") + } + elseif (-not (Test-EntryPointExport -AssemblyPath $plugin)) { + $problems.Add("$PluginFileName declares no public static CESDK.CESDK.CEPluginInitialize(nint, int).") + } + + foreach ($assembly in $script:SdkAssemblies) { + if (-not (Test-Path -LiteralPath (Join-Path $BundleDirectory $assembly) -PathType Leaf)) { + $problems.Add("The SDK assembly $assembly is missing.") + } + } + + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($PluginFileName) + $deps = Join-Path $BundleDirectory "$baseName.deps.json" + if (-not (Test-Path -LiteralPath $deps -PathType Leaf)) { + $problems.Add("$baseName.deps.json is missing.") + } + else { + $depsText = [System.IO.File]::ReadAllText($deps) + if ($depsText -match '"type"\s*:\s*"project"') { + $problems.Add("$baseName.deps.json contains a workspace project entry; the SDK must come from the package.") + } + if ($depsText -match '(? + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $WorkRoot, + [Parameter(Mandatory)] [string] $RepositoryRoot + ) + + $full = [System.IO.Path]::GetFullPath($WorkRoot).TrimEnd('\') + '\' + $repositoryParent = [System.IO.Path]::GetFullPath((Join-Path $RepositoryRoot '..')).TrimEnd('\') + '\' + if ($full.StartsWith($repositoryParent, [System.StringComparison]::OrdinalIgnoreCase)) { + return "The work root '$WorkRoot' is below the repository's parent directory; bundles must not see workspace files." + } + + for ($directory = [System.IO.DirectoryInfo]::new($full); $null -ne $directory; $directory = $directory.Parent) { + if (Test-Path -LiteralPath (Join-Path $directory.FullName '.git')) { + return "The work root '$WorkRoot' is inside the git work tree '$($directory.FullName)'." + } + } + + return $null +} + +function Get-CiEnvironmentVariable { + <# + .SYNOPSIS + Returns the name of the first non-empty CI marker variable (CI, GITHUB_ACTIONS, TF_BUILD), or $null. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + foreach ($name in 'CI', 'GITHUB_ACTIONS', 'TF_BUILD') { + if (-not [string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($name))) { return $name } + } + + return $null +} + +function Test-HasProperty { + <# + .SYNOPSIS + True when an object (a JSON-parsed PSCustomObject or a dictionary) has the named property; safe in strict mode. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [AllowNull()] [object] $InputObject, + [Parameter(Mandatory)] [string] $Name + ) + + if ($null -eq $InputObject) { return $false } + if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject.Contains($Name) } + return @($InputObject.PSObject.Properties.Name) -ccontains $Name +} + +function Get-OptionalProperty { + <# + .SYNOPSIS + The named property of an object, or $null when it is absent; safe in strict mode. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] [AllowNull()] [object] $InputObject, + [Parameter(Mandatory)] [string] $Name + ) + + if (-not (Test-HasProperty -InputObject $InputObject -Name $Name)) { return $null } + if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject[$Name] } + return $InputObject.$Name +} + +function Get-StepValue { + <# + .SYNOPSIS + Reads a value of a recorded step: 'N' is the Nth returned Lua value (0-based); any other selector is a dotted + JSON path into the first returned value, parsed as JSON. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] [AllowNull()] [object] $Step, + [Parameter(Mandatory)] [string] $Selector + ) + + $values = @(Get-OptionalProperty -InputObject $Step -Name 'values') + if ($values.Count -eq 1 -and $null -eq $values[0]) { $values = @() } + if ($Selector -match '^\d+$') { + $index = [int] $Selector + if ($index -ge $values.Count) { return $null } + return Get-OptionalProperty -InputObject $values[$index] -Name 'value' + } + + if ($values.Count -eq 0 -or (Get-OptionalProperty -InputObject $values[0] -Name 'type') -ne 'string') { return $null } + try { $current = $values[0].value | ConvertFrom-Json -Depth 64 } + catch { return $null } + foreach ($segment in $Selector.Split('.')) { + if (-not (Test-HasProperty -InputObject $current -Name $segment)) { return $null } + $current = Get-OptionalProperty -InputObject $current -Name $segment + } + + return $current +} + +function Resolve-QualificationOutcome { + <# + .SYNOPSIS + Evaluates a scenario pass rule on the recorded, redacted step results and operator answers. Returns the receipt + status, pass kind, observed text and justification; Inconclusive means no receipt is written. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] [object] $Scenario, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Steps, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Answers, + [string] $PackagedBridgeSha256 = '' + ) + + $rule = $Scenario.passRule + $results = [System.Collections.Generic.List[string]]::new() + $failed = 0 + foreach ($check in @($rule.checks)) { + $passed = $false + $label = $check.step + if (Test-HasProperty -InputObject $check -Name 'answer') { + $answer = [string] $Answers[$check.step] + $passed = $answer.Trim().StartsWith([string] $check.answer, [System.StringComparison]::OrdinalIgnoreCase) + $label = "$($check.step) answered '$($answer.Trim())'" + } + else { + $step = $Steps[$check.step] + $passed = Test-StepCheck -Check $check -Step $step -Steps $Steps -PackagedBridgeSha256 $PackagedBridgeSha256 + $label = Format-StepCheck -Check $check -Step $step + } + + if (-not $passed) { $failed++ } + $results.Add("$(if ($passed) { 'ok' } else { 'FAILED' }): $label") + } + + $observe = if (Test-HasProperty -InputObject $Scenario -Name 'observe') { @($Scenario.observe) } else { @() } + foreach ($selector in $observe) { + $stepId, $path = $selector.Split(':', 2) + $value = Get-StepValue -Step $Steps[$stepId] -Selector $path + $results.Add("observed $($stepId):$path = $(ConvertTo-Json -InputObject $value -Compress -Depth 8)") + } + + $observed = $results -join '; ' + $outcome = [ordered]@{ status = 'Failed'; passKind = $null; observed = $observed; justification = $null } + if (Test-HasProperty -InputObject $rule -Name 'requiresAnswer') { + $required = $rule.requiresAnswer + if (-not ([string] $Answers[$required.step]).Trim().StartsWith([string] $required.answer, [System.StringComparison]::OrdinalIgnoreCase)) { + $outcome.status = 'Inconclusive' + $outcome.justification = "The operator did not perform step '$($required.step)'; no receipt is written." + return $outcome + } + } + + switch ($rule.kind) { + 'NotApplicableObservation' { + $outcome.status = if ($failed -eq 0) { 'NotApplicable' } else { 'Inconclusive' } + $outcome.justification = if ($failed -eq 0) { [string] (Get-OptionalProperty -InputObject $rule -Name 'justification') } else { 'The observation the NotApplicable decision records was not obtained.' } + } + default { + if ($failed -eq 0) { + $outcome.status = 'Passed' + $outcome.passKind = [string] (Get-OptionalProperty -InputObject $rule -Name 'passKind') + } + else { + $outcome.justification = "$failed check(s) of the pass rule failed; see observed." + } + } + } + + return $outcome +} + +function Test-StepCheck { + <# + .SYNOPSIS + Evaluates one non-operator check of a pass rule against a recorded step. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] [object] $Check, + [Parameter(Mandatory)] [AllowNull()] [object] $Step, + [Parameter(Mandatory)] [System.Collections.IDictionary] $Steps, + [string] $PackagedBridgeSha256 = '' + ) + + if ($null -eq $Step) { return $false } + $names = @($Check.PSObject.Properties.Name) + if ($names -contains 'ok') { return [bool] $Step.ok -eq [bool] $Check.ok } + if ($names -contains 'errorContains') { + $text = [string] (Get-StepValue -Step $Step -Selector '0') + return (-not [bool] $Step.ok) -and $text.Contains([string] $Check.errorContains, [System.StringComparison]::Ordinal) + } + + $value = Get-StepValue -Step $Step -Selector ([string] $Check.json) + if ($names -contains 'equals') { return ($null -ne $value) -and ($value -ceq $Check.equals) } + if ($names -contains 'present') { return $null -ne $value } + if ($names -contains 'atLeast') { return ($null -ne $value) -and ([double] $value -ge [double] $Check.atLeast) } + if ($names -contains 'contains') { return @($value) -ccontains $Check.contains } + if ($names -contains 'startsWith') { return ($null -ne $value) -and ([string] $value).StartsWith([string] $Check.startsWith, [System.StringComparison]::Ordinal) } + if ($names -contains 'equalsPackagedBridge') { + return ($PackagedBridgeSha256.Length -eq 64) -and ([string] $value).Equals($PackagedBridgeSha256, [System.StringComparison]::OrdinalIgnoreCase) + } + if ($names -contains 'greaterThan' -or $names -contains 'sameAs') { + $reference = if ($names -contains 'greaterThan') { $Check.greaterThan } else { $Check.sameAs } + $other = Get-StepValue -Step $Steps[$reference.step] -Selector ([string] $reference.json) + if ($null -eq $value -or $null -eq $other) { return $false } + if ($names -contains 'greaterThan') { return [double] $value -gt [double] $other } + return $value -ceq $other + } + + throw "Unknown pass-rule check: $(ConvertTo-Json -InputObject $Check -Compress)." +} + +function Format-StepCheck { + <# + .SYNOPSIS + A one-line description of a check and of the value it saw, for the receipt's observed text. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [object] $Check, + [Parameter(Mandatory)] [AllowNull()] [object] $Step + ) + + $names = @($Check.PSObject.Properties.Name) + if ($null -eq $Step) { return "$($Check.step) was not recorded" } + if ($names -contains 'ok') { return "$($Check.step).ok is $(([bool] $Step.ok).ToString().ToLowerInvariant()), expected $(([bool] $Check.ok).ToString().ToLowerInvariant())" } + if ($names -contains 'errorContains') { return "$($Check.step) error contains '$($Check.errorContains)'" } + $value = Get-StepValue -Step $Step -Selector ([string] $Check.json) + $condition = ($names | Where-Object { $_ -notin 'step', 'json' }) -join ',' + return "$($Check.step):$($Check.json) = $(ConvertTo-Json -InputObject $value -Compress -Depth 8) ($condition)" +} + +function ConvertTo-RedactedSessionEvent { + <# + .SYNOPSIS + Stage 13: redacts every event and returns the event log entries and the step results the pass rule reads. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Raw, + [Parameter(Mandatory)] [object[]] $Map, + [Parameter(Mandatory)] [long] $OffsetMs + ) + + $entries = [System.Collections.Generic.List[object]]::new() + $steps = [ordered]@{} + foreach ($item in $Raw) { + $message = [string] $item.message + if ($item.kind -eq 'StepResult') { + $result = $message | ConvertFrom-Json -Depth 64 + foreach ($value in @($result.values)) { + if ($null -ne $value -and (Test-HasProperty -InputObject $value -Name 'value') -and $value.value -is [string]) { + $value.value = ConvertTo-RedactedText -Text $value.value -Map $Map + } + } + $steps[[string] $result.id] = $result + $message = ConvertTo-Json -InputObject $result -Compress -Depth 64 + } + else { + $message = ConvertTo-RedactedText -Text $message -Map $Map + } + + $time = [long] $item.tMs + $entries.Add([ordered]@{ tMs = [long] [Math]::Max(0, $(if ($item.source -eq 'Runner') { $time } else { $OffsetMs + $time })); source = [string] $item.source; kind = [string] $item.kind; message = $message }) + } + + return [ordered]@{ entries = @($entries | Sort-Object -Property { $_.tMs } -Stable); steps = $steps } +} + +function ConvertTo-QualificationReceipt { + <# + .SYNOPSIS + Assembles a receipt (schema cheatengine-qualification-receipt/v0) in schema order from the run context. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param([Parameter(Mandatory)] [System.Collections.IDictionary] $Context) + + $receiptId = Get-QualificationReceiptId -StartedUtc $Context.StartedUtc -QualificationId $Context.QualificationId -NupkgSha256 $Context.Package.nupkgSha256 + $outcome = $Context.Outcome + $timings = [ordered]@{ + startedUtc = Format-QualificationUtc -Value $Context.StartedUtc + finishedUtc = Format-QualificationUtc -Value $Context.FinishedUtc + durationMs = [long] [Math]::Max(0, ($Context.FinishedUtc - $Context.StartedUtc).TotalMilliseconds) + } + if ($Context.Contains('CeStartMs')) { $timings.ceStartMs = [long] $Context.CeStartMs } + if ($Context.Contains('Indicative') -and $Context.Indicative) { $timings.indicative = $true } + + return [ordered]@{ + schema = 'cheatengine-qualification-receipt/v0' + receiptId = $receiptId + qualificationId = $Context.QualificationId + level = $Context.Level + profileId = $Context.ProfileId + operator = $Context.Operator + loadRoute = $Context.LoadRoute + repository = $Context.Repository + runner = $Context.Runner + package = $Context.Package + host = $Context.Host + bridge = $Context.Bridge + bundles = @($Context.Bundles) + target = $Context.Target + registry = $Context.Registry + preconditions = @($Context.Preconditions) + operation = $Context.Operation + expected = $Context.Expected + observed = $outcome.observed + status = $outcome.status + passKind = $outcome.passKind + evidenceKind = 'ObservedHost' + justification = $outcome.justification + timings = $timings + eventLog = [ordered]@{ + path = "$receiptId.events.json" + sha256 = $Context.EventLogSha256 + format = 'cheatengine-qualification-events/v0' + redactions = @($Context.Redactions) + } + transferJustification = $null + createdUtc = Format-QualificationUtc -Value $Context.CreatedUtc + } +} + +Export-ModuleMember -Function @( + 'Get-QualificationFileSha256' + 'Get-QualificationTextSha256' + 'ConvertFrom-RegistryExport' + 'Read-RegistryExport' + 'Compare-RegistrySnapshot' + 'Get-RedactionMap' + 'ConvertTo-RedactedText' + 'Limit-QualificationEventLog' + 'Get-QualificationReceiptId' + 'Format-QualificationUtc' + 'ConvertTo-QualificationJson' + 'ConvertTo-LuaLiteral' + 'Expand-QualificationDriver' + 'Get-BundleFileManifest' + 'Test-EntryPointExport' + 'Test-QualificationBundleClosure' + 'Test-QualificationWorkRoot' + 'Get-CiEnvironmentVariable' + 'Test-HasProperty' + 'Get-OptionalProperty' + 'Get-StepValue' + 'Resolve-QualificationOutcome' + 'Test-StepCheck' + 'Format-StepCheck' + 'ConvertTo-RedactedSessionEvent' + 'ConvertTo-QualificationReceipt' +) diff --git a/eng/qualification/driver/zz_cesdk_qualification.template.lua b/eng/qualification/driver/zz_cesdk_qualification.template.lua new file mode 100644 index 00000000..eb536421 --- /dev/null +++ b/eng/qualification/driver/zz_cesdk_qualification.template.lua @@ -0,0 +1,244 @@ +-- CheatEngine.SDK qualification driver (autorun). Generated by eng/qualification/Invoke-LocalQualification.ps1 from +-- eng/qualification/driver/zz_cesdk_qualification.template.lua into the SANDBOX copy's autorun folder only, and removed +-- by the runner when the run ends. It never runs from an installed Cheat Engine. +-- +-- The driver is a recorder: it executes the scenario steps the runner generated, one per timer tick, and appends one +-- JSON event per line to the run's event file. It never judges a result; the runner evaluates the pass rule. +-- Every host call is protected with pcall. The driver is idempotent across a Lua state reset: it persists the index of +-- the last completed step and resumes after it when autorun loads it again. + +local RUN = { + id = __RUN_ID__, + events = __EVENTS_PATH__, + progress = __PROGRESS_PATH__, + handshakeDir = __HANDSHAKE_DIR__, + done = __DONE_PATH__, + tickMs = 250, +} +local STEPS = __STEPS__ +local BUNDLES = __BUNDLES__ +local TARGETS = __TARGETS__ + +local function fileExists(path) + local f = io.open(path, 'rb') + if f == nil then return false end + f:close() + return true +end + +local function readAll(path) + local f = io.open(path, 'rb') + if f == nil then return nil end + local text = f:read('a') + f:close() + return text +end + +local function milliseconds() + local ok, ticks = pcall(getTickCount) + if ok and math.type(ticks) == 'integer' then return ticks end + return math.floor(os.clock() * 1000) +end + +local T0 = milliseconds() + +-- ---------------------------------------------------------------- JSON (strings, numbers, booleans, arrays, objects) +local ESCAPES = { ['"'] = '\\"', ['\\'] = '\\\\', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t' } + +local function jsonString(s) + return '"' .. s:gsub('[%c"\\]', function(c) return ESCAPES[c] or string.format('\\u%04x', c:byte()) end) .. '"' +end + +local encode +local function isArray(t) + local count = 0 + for key in pairs(t) do + if math.type(key) ~= 'integer' or key < 1 then return false end + count = count + 1 + end + for index = 1, count do + if t[index] == nil then return false end + end + return true +end + +encode = function(value) + local kind = type(value) + if value == nil then return 'null' end + if kind == 'boolean' then return value and 'true' or 'false' end + if kind == 'number' then + if math.type(value) == 'integer' then return string.format('%d', value) end + if value ~= value or value == math.huge or value == -math.huge then return jsonString(tostring(value)) end + return string.format('%.17g', value) + end + if kind == 'string' then return jsonString(value) end + if kind == 'table' then + local parts = {} + if isArray(value) then + for index = 1, #value do parts[#parts + 1] = encode(value[index]) end + return '[' .. table.concat(parts, ',') .. ']' + end + local keys = {} + for key in pairs(value) do keys[#keys + 1] = tostring(key) end + table.sort(keys) + for _, key in ipairs(keys) do parts[#parts + 1] = jsonString(key) .. ':' .. encode(value[key]) end + return '{' .. table.concat(parts, ',') .. '}' + end + return jsonString('<' .. kind .. '>') +end + +-- ---------------------------------------------------------------- events and progress +local function emit(source, kind, message) + local line = encode({ tMs = milliseconds() - T0, source = source, kind = kind, message = message }) + local f = io.open(RUN.events, 'ab') + if f == nil then return false end + f:write(line, '\n') + f:close() + return true +end + +local function readProgress() + local text = readAll(RUN.progress) + if text == nil then return 0 end + local runId, index = text:match('^(%S+)%s+(%d+)') + if runId ~= RUN.id then return 0 end + return tonumber(index) +end + +local function writeProgress(index) + local f = io.open(RUN.progress, 'wb') + if f == nil then return end + f:write(RUN.id, ' ', tostring(index)) + f:close() +end + +-- A Lua value as a JSON-safe, type-tagged record, so nil, false, 0 and '' stay distinguishable. +local function describe(value) + local kind = type(value) + if kind == 'nil' then return { type = 'nil' } end + if kind == 'string' or kind == 'boolean' then return { type = kind, value = value } end + if kind == 'number' then return { type = math.type(value) or 'number', value = value } end + return { type = kind, value = tostring(value) } +end + +local function describeAll(packed, first) + local values = {} + for index = first, packed.n do values[#values + 1] = describe(packed[index]) end + return values +end + +-- ---------------------------------------------------------------- steps +local ACTIONS = {} + +ACTIONS.openTarget = function(step) + local pid = TARGETS[step.target] + if pid == nil then return false, { describe('unknown target ' .. tostring(step.target)) } end + local packed = table.pack(pcall(openProcess, pid)) + return packed[1], describeAll(packed, 2) +end + +ACTIONS.loadPlugin = function(step) + local path = BUNDLES[step.bundle] + if path == nil then return false, { describe('unknown bundle ' .. tostring(step.bundle)) } end + local packed = table.pack(pcall(loadPlugin, path)) + return packed[1], describeAll(packed, 2) +end + +ACTIONS.call = function(step) + local target = _G[step['function']] + if type(target) ~= 'function' then + return false, { describe('global ' .. step['function'] .. ' is ' .. type(target)) } + end + local packed = table.pack(pcall(target, table.unpack(step.args or {}))) + return packed[1], describeAll(packed, 2) +end + +local state = { index = 0, waitUntil = nil, awaiting = false, finished = false, timer = nil } + +local function finish(reason) + if state.finished then return end + state.finished = true + emit('Driver', 'Completed', reason) + local f = io.open(RUN.done, 'wb') + if f ~= nil then f:write(RUN.id) f:close() end + if state.timer ~= nil then + pcall(function() state.timer.Enabled = false end) + end + pcall(closeCE) +end + +local function runStep(index, step) + if step.kind == 'Operator' then + local handshake = RUN.handshakeDir .. '/step-' .. tostring(index) .. '.txt' + if not state.awaiting then + state.awaiting = true + emit('Driver', 'AwaitOperator', encode({ step = index, id = step.id, instruction = step.instruction })) + return false + end + local note = readAll(handshake) + if note == nil then return false end + state.awaiting = false + emit('Operator', 'Observation', encode({ step = index, id = step.id, note = note })) + return true + end + + if step.action == 'wait' then + if state.waitUntil == nil then + state.waitUntil = milliseconds() + (step.ms or 0) + return false + end + if milliseconds() < state.waitUntil then return false end + state.waitUntil = nil + emit('Driver', 'StepResult', encode({ step = index, id = step.id, action = 'wait', ok = true, values = {} })) + return true + end + + local action = ACTIONS[step.action] + local ok, values + if action == nil then + ok, values = false, { describe('unknown action ' .. tostring(step.action)) } + else + local protected, first, second = pcall(action, step) + if protected then ok, values = first, second else ok, values = false, { describe(first) } end + end + emit('Driver', 'StepResult', encode({ step = index, id = step.id, action = step.action, ok = ok, values = values })) + return true +end + +local function tick() + if state.finished then return end + local step = STEPS[state.index + 1] + if step == nil then + finish('all steps executed') + return + end + local advanced = runStep(state.index + 1, step) + if advanced then + state.index = state.index + 1 + writeProgress(state.index) + end +end + +local function start() + if fileExists(RUN.done) then return end + state.index = readProgress() + emit('Driver', state.index == 0 and 'Started' or 'Resumed', + encode({ runId = RUN.id, step = state.index, inMainThread = (type(inMainThread) == 'function') and inMainThread() or nil })) + local timer = createTimer(nil, false) + timer.Interval = RUN.tickMs + timer.OnTimer = function() + local ok, failure = pcall(tick) + if not ok then + emit('Driver', 'DriverError', tostring(failure)) + finish('driver error') + end + end + state.timer = timer + timer.Enabled = true +end + +local ok, failure = pcall(start) +if not ok then + emit('Driver', 'DriverError', tostring(failure)) + pcall(closeCE) +end diff --git a/eng/qualification/scenarios.json b/eng/qualification/scenarios.json new file mode 100644 index 00000000..f503d997 --- /dev/null +++ b/eng/qualification/scenarios.json @@ -0,0 +1,419 @@ +{ + "schema": "cheatengine-qualification-scenarios/v0", + "description": "Runner-internal plan of the exact-host scenarios. Preconditions, operation and expected result come from docs/qualification/matrix.json and are never repeated here. Steps run in order: Lua steps are executed by the autorun driver, Operator steps wait for the operator at the runner prompt. The runner evaluates passRule on the recorded, redacted events.", + "scenarios": [ + { + "id": "Q02", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" }, + { "id": "review", "kind": "Operator", "instruction": "Review bootstrap.tailCanaryWritten, tailBeforeHex and tailFailure in the status record printed above. Type y if only the 36 record bytes were written and the tail observation is complete, n otherwise." } + ], + "observe": [ "status:bootstrap.tailCanaryWritten", "status:bootstrap.tailBeforeHex", "status:bootstrap.tailFailure" ], + "passRule": { + "kind": "OperatorJudgement", + "passKind": "Functional", + "checks": [ + { "step": "load", "ok": true }, + { "step": "status", "json": "bootstrap.tailCanaryWritten", "equals": true }, + { "step": "review", "answer": "y" } + ] + } + }, + { + "id": "Q03", + "level": "C3", + "support": "Automated", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" } + ], + "observe": [ "status:context.reportedExportsSize" ], + "passRule": { + "kind": "NotApplicableObservation", + "justification": "The exact host hands a complete managed exports record (the observed size is recorded); a reduced or missing record cannot be produced on Cheat Engine 7.7 without modifying it. The C1 refusal tests are the evidence of the refusal.", + "checks": [ + { "step": "status", "json": "context.reportedExportsSize", "present": true } + ] + } + }, + { + "id": "Q04", + "level": "C3", + "support": "Automated", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" } + ], + "observe": [ "status:bootstrap.opaqueSecondInt", "status:bootstrap.pluginHostLastInitRecordArgument", "status:bootstrap.calls" ], + "passRule": { + "kind": "Automated", + "passKind": "Functional", + "checks": [ + { "step": "load", "ok": true }, + { "step": "status", "ok": true }, + { "step": "status", "json": "bootstrap.calls", "atLeast": 1 }, + { "step": "status", "json": "bootstrap.opaqueSecondInt", "present": true }, + { "step": "status", "json": "bootstrap.interpretation", "equals": "none" } + ] + } + }, + { + "id": "Q05", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "first", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" }, + { "id": "cycle", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK CE 7.7 Live Probe', apply, then tick it again and apply. Type the plugin name exactly as the list shows it, before and after." }, + { "id": "second", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" }, + { "id": "stable", "kind": "Operator", "instruction": "Type y if the name shown was the same before and after the cycle, n otherwise." } + ], + "observe": [ "first:context.pluginId", "first:context.epoch", "second:context.pluginId", "second:context.epoch", "second:identity.pluginAssemblyMvid" ], + "passRule": { + "kind": "Automated", + "passKind": "Functional", + "checks": [ + { "step": "first", "ok": true }, + { "step": "second", "ok": true }, + { "step": "second", "json": "context.epoch", "greaterThan": { "step": "first", "json": "context.epoch" } }, + { "step": "second", "json": "identity.pluginAssemblyMvid", "sameAs": { "step": "first", "json": "identity.pluginAssemblyMvid" } }, + { "step": "stable", "answer": "y" } + ] + } + }, + { + "id": "Q05.a", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbeNonAscii" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbeNonAscii" }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" }, + { "id": "name", "kind": "Operator", "instruction": "Open Edit > Settings > Plugins and type the plugin name exactly as the list shows it (the SDK name is 'CheatEngine.SDK Live Probe' + e-acute + two CJK characters)." }, + { "id": "intact", "kind": "Operator", "instruction": "Type y if the list and the plugin behave normally (only the rendering of the three characters may differ), n if the name or the list is corrupted." } + ], + "observe": [ "status:context.pluginId" ], + "passRule": { + "kind": "OperatorJudgement", + "passKind": "Functional", + "checks": [ + { "step": "load", "ok": true }, + { "step": "status", "ok": true }, + { "step": "intact", "answer": "y" } + ] + } + }, + { + "id": "Q06", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "faultStage": "OnEnable", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "failed", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" }, + { "id": "reenable", "kind": "Operator", "removeFaultFile": true, "instruction": "The runner removes liveprobe.fault.json now. In Edit > Settings > Plugins tick 'CheatEngine.SDK CE 7.7 Live Probe' again (untick first if it shows ticked) and apply. Type what Cheat Engine showed when the first enable failed." }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" } + ], + "observe": [ "load:0", "failed:0", "status:faultInjection.injected", "status:context.epoch" ], + "passRule": { + "kind": "Automated", + "passKind": "Functional", + "checks": [ + { "step": "failed", "ok": false }, + { "step": "status", "ok": true }, + { "step": "status", "json": "faultInjection.injected", "contains": "OnEnable@1" }, + { "step": "status", "json": "context.present", "equals": true } + ] + } + }, + { + "id": "Q07", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "ready", "kind": "Operator", "instruction": "Open Edit > Settings > Plugins now. After you press Enter the plugin pumps messages for 20 seconds: untick 'CheatEngine.SDK CE 7.7 Live Probe' and apply during that time. Type anything to start." }, + { "id": "pump", "kind": "Lua", "action": "call", "function": "ce77_live_probe_pump_messages", "args": [ 20 ] }, + { "id": "acted", "kind": "Operator", "instruction": "Type y if you unticked and applied while the pump ran, n otherwise; add what Cheat Engine showed." }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" } + ], + "observe": [ "pump:phaseSequence", "pump:enabledAfter", "pump:pumps", "status:context.phase" ], + "passRule": { + "kind": "Automated", + "passKind": "RefusalVerified", + "requiresAnswer": { "step": "acted", "answer": "y" }, + "checks": [ + { "step": "pump", "ok": true }, + { "step": "pump", "json": "enabledAfter", "equals": true }, + { "step": "pump", "json": "phaseAfter", "equals": "Enabled" } + ] + } + }, + { + "id": "Q08", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "faultStage": "OnDisable", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "before", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" }, + { "id": "disable", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK CE 7.7 Live Probe' and apply (OnDisable throws by liveprobe.fault.json). Type what Cheat Engine showed." }, + { "id": "after", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" }, + { "id": "reenable", "kind": "Operator", "removeFaultFile": true, "instruction": "The runner removes liveprobe.fault.json now. Tick the plugin again and apply. Type y if Cheat Engine never showed the plugin as cleanly disabled while its cleanup had failed, n otherwise." }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" } + ], + "observe": [ "after:0", "status:faultInjection.injected", "status:context.epoch" ], + "passRule": { + "kind": "OperatorJudgement", + "passKind": "Functional", + "checks": [ + { "step": "before", "ok": true }, + { "step": "status", "ok": true }, + { "step": "status", "json": "faultInjection.injected", "contains": "OnDisable@1" }, + { "step": "reenable", "answer": "y" } + ] + } + }, + { + "id": "Q14", + "level": "C3", + "support": "Automated", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "throw", "kind": "Lua", "action": "call", "function": "ce77_live_probe_throw_managed_exception" }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" } + ], + "observe": [ "throw:0", "status:managedExceptionThrows" ], + "passRule": { + "kind": "Automated", + "passKind": "Functional", + "checks": [ + { "step": "load", "ok": true }, + { "step": "throw", "ok": false }, + { "step": "throw", "errorContains": "deliberate managed exception" }, + { "step": "status", "ok": true }, + { "step": "status", "json": "managedExceptionThrows", "atLeast": 1 } + ] + } + }, + { + "id": "Q15", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "prepare", "kind": "Lua", "action": "call", "function": "ce77_live_probe_prepare_callback_shutdown" }, + { "id": "live", "kind": "Lua", "action": "call", "function": "ce77_live_probe_callback_shutdown" }, + { "id": "disable", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK CE 7.7 Live Probe' and apply. Type anything to continue." }, + { "id": "kept", "kind": "Lua", "action": "call", "function": "ce77_live_probe_callback_shutdown" } + ], + "observe": [ "live:0", "kept:0" ], + "passRule": { + "kind": "Automated", + "passKind": "Functional", + "checks": [ + { "step": "live", "ok": true }, + { "step": "kept", "ok": false }, + { "step": "kept", "errorContains": "released" } + ] + } + }, + { + "id": "Q17", + "level": "C3", + "support": "Manual", + "reason": "No SDK-controlled Lua state replacement can be triggered inside the exact host with the current harnesses (LuaRuntime.BeginStateReset has no host entry point). The external resetLuaState observation is Q18; Q17 C3 waits for the S-HOST reset API." + }, + { + "id": "Q18", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "before", "kind": "Lua", "action": "call", "function": "ce77_live_probe_snapshot_before_reset" }, + { "id": "reset", "kind": "Operator", "instruction": "Open Table > Show Cheat Table Lua Script or the Lua Engine and execute resetLuaState(). The driver resumes from its progress file if autorun loads it again. Type what happened." }, + { "id": "after", "kind": "Lua", "action": "call", "function": "ce77_live_probe_snapshot_after_reset" }, + { "id": "judge", "kind": "Operator", "instruction": "Type y if the recorded outcome reports the external reset as unsupported and claims no safety (whatever the old reference did), n otherwise." } + ], + "observe": [ "before:0", "after:0" ], + "passRule": { + "kind": "OperatorJudgement", + "passKind": "Functional", + "checks": [ + { "step": "before", "ok": true }, + { "step": "judge", "answer": "y" } + ] + } + }, + { + "id": "Q19", + "level": "C3", + "support": "Guided", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "begin", "kind": "Lua", "action": "call", "function": "ce77_live_probe_begin_lua_threads" }, + { "id": "settle", "kind": "Lua", "action": "wait", "ms": 1500 }, + { "id": "threads", "kind": "Lua", "action": "call", "function": "ce77_live_probe_lua_threads_status" }, + { "id": "sync", "kind": "Lua", "action": "call", "function": "ce77_live_probe_begin_synchronize" }, + { "id": "settle2", "kind": "Lua", "action": "wait", "ms": 1500 }, + { "id": "syncStatus", "kind": "Lua", "action": "call", "function": "ce77_live_probe_synchronize_status" }, + { "id": "judge", "kind": "Operator", "instruction": "Review the worker observations above. Type y if a first worker call was either serialized as the policy states or refused, n otherwise." } + ], + "observe": [ "threads:0", "syncStatus:0" ], + "passRule": { + "kind": "OperatorJudgement", + "passKind": "Functional", + "checks": [ + { "step": "threads", "ok": true }, + { "step": "judge", "answer": "y" } + ] + } + }, + { + "id": "Q39", + "level": "C3", + "support": "NotApplicable", + "reason": "Profile decision CPA-2: a managed-hostfxr plugin never receives the classic exports table; the matrix records Q39 C3 as NotApplicable without a run." + }, + { + "id": "Q40", + "level": "C3", + "support": "Automated", + "harnesses": [ "LiveProbe" ], + "target": "x64", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "open", "kind": "Lua", "action": "openTarget", "target": "x64" }, + { "id": "load", "kind": "Lua", "action": "loadPlugin", "bundle": "LiveProbe" }, + { "id": "status", "kind": "Lua", "action": "call", "function": "ce77_live_probe_status_json" }, + { "id": "profile", "kind": "Lua", "action": "call", "function": "ce77_live_probe_host_profile" } + ], + "observe": [ "status:identity.pluginAssemblyLocation", "status:identity.hostingAssemblyLocation", "profile:bridge.path", "profile:bridge.sha256" ], + "passRule": { + "kind": "Automated", + "passKind": "Functional", + "checks": [ + { "step": "load", "ok": true }, + { "step": "status", "json": "identity.pluginAssemblyLocation", "startsWith": "" }, + { "step": "status", "json": "identity.hostingAssemblyLocation", "startsWith": "" }, + { "step": "profile", "json": "bridge.path", "startsWith": "" }, + { "step": "profile", "json": "bridge.sha256", "equalsPackagedBridge": true } + ] + } + }, + { + "id": "Q09.a", + "level": "C4", + "support": "Guided", + "harnesses": [ "CoexistenceShared" ], + "target": "none", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "loadA", "kind": "Lua", "action": "loadPlugin", "bundle": "CoexistenceSharedA" }, + { "id": "loadB", "kind": "Lua", "action": "loadPlugin", "bundle": "CoexistenceSharedB" }, + { "id": "identityA", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_identity" }, + { "id": "identityB", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_identity" }, + { "id": "removeA", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK Coexistence Plugin A' only, and apply. Type anything to continue." }, + { "id": "pingB", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_ping" }, + { "id": "pingA", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_ping" }, + { "id": "restoreA", "kind": "Operator", "instruction": "Tick 'CheatEngine.SDK Coexistence Plugin A' again and apply. Type anything to continue." }, + { "id": "pingA2", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_ping" }, + { "id": "pingB2", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_ping" } + ], + "observe": [ "identityA:0", "identityB:0", "pingA:0", "pingB:0" ], + "passRule": { + "kind": "Automated", + "passKind": "Functional", + "checks": [ + { "step": "identityA", "ok": true }, + { "step": "identityB", "ok": true }, + { "step": "pingB", "ok": true }, + { "step": "pingA2", "ok": true }, + { "step": "pingB2", "ok": true } + ] + } + }, + { + "id": "Q09.b", + "level": "C4", + "support": "Guided", + "harnesses": [ "CoexistenceA", "CoexistenceB" ], + "target": "none", + "loadRoute": "LuaLoadPlugin", + "steps": [ + { "id": "loadA", "kind": "Lua", "action": "loadPlugin", "bundle": "CoexistenceA" }, + { "id": "loadB", "kind": "Lua", "action": "loadPlugin", "bundle": "CoexistenceB" }, + { "id": "identityA", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_identity" }, + { "id": "identityB", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_identity" }, + { "id": "removeA", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK Coexistence Plugin A' only, and apply. Type anything to continue." }, + { "id": "pingB", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_ping" }, + { "id": "pingA", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_ping" }, + { "id": "restoreA", "kind": "Operator", "instruction": "Tick 'CheatEngine.SDK Coexistence Plugin A' again and apply. Type anything to continue." }, + { "id": "pingA2", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_ping" }, + { "id": "pingB2", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_ping" } + ], + "observe": [ "identityA:0", "identityB:0", "pingA:0", "pingB:0" ], + "passRule": { + "kind": "Automated", + "passKind": "Functional", + "checks": [ + { "step": "identityA", "ok": true }, + { "step": "identityB", "ok": true }, + { "step": "pingB", "ok": true }, + { "step": "pingA2", "ok": true }, + { "step": "pingB2", "ok": true } + ] + } + } + ] +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs new file mode 100644 index 00000000..e519b776 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -0,0 +1,523 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +namespace CheatEngine.SDK.Repository.Tests.Qualification; + +/// +/// The local exact-host runner (eng/qualification) without Cheat Engine: only its CI guard runs the script; the +/// other tests read it statically or import its pure module into pwsh with synthetic input. Nothing here takes +/// the Global\ce-lab mutex, touches HKCU or reads the Cheat Engine installation. +/// +public sealed class LocalQualificationRunnerTests +{ + private const string RunnerScript = "eng/qualification/Invoke-LocalQualification.ps1"; + private const string RunnerModule = "eng/qualification/QualificationRunner.psm1"; + private const string Scenarios = "eng/qualification/scenarios.json"; + private const string DriverTemplate = "eng/qualification/driver/zz_cesdk_qualification.template.lua"; + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + private static readonly string[] CiMarkers = ["CI", "GITHUB_ACTIONS", "TF_BUILD"]; + + private static readonly string[] CheckpointB = + [ + "Q02", "Q03", "Q04", "Q05", "Q05.a", "Q06", "Q07", "Q08", "Q09.a", "Q09.b", "Q14", "Q15", "Q17", "Q18", "Q19", + "Q39", "Q40" + ]; + + private static string ModuleImport => + "Import-Module " + PowerShellProcess.Quote(QualificationDocuments.Absolute(RunnerModule)) + " -Force; "; + + [Theory] + [InlineData("CI")] + [InlineData("GITHUB_ACTIONS")] + [InlineData("TF_BUILD")] + public void Runner_refuses_to_run_under_CI_before_any_side_effect(string marker) + { + string workRoot = Path.Combine(Path.GetTempPath(), "cesdk-guard-" + Guid.NewGuid().ToString("N")); + Dictionary environment = new(StringComparer.Ordinal); + foreach (string name in CiMarkers) + { + environment[name] = string.Equals(name, marker, StringComparison.Ordinal) ? "true" : null; + } + + PowerShellProcess.Result result = PowerShellProcess.RunFile(QualificationDocuments.Absolute(RunnerScript), + ["-Scenario", "Q04", "-PackagePath", "missing.nupkg", "-WorkRoot", workRoot], environment, + TestContext.Current.CancellationToken); + + Assert.True(result.ExitCode == 3, result.Transcript); + Assert.Contains(marker + " is set", result.Error, StringComparison.Ordinal); + Assert.False(Directory.Exists(workRoot), "The guard must run before anything is created."); + } + + [Fact] + public void No_workflow_references_the_local_qualification_runner() + { + List scanned = []; + List offenders = []; + string github = QualificationDocuments.Absolute(".github"); + foreach (string file in Directory.EnumerateFiles(github, "*.*", SearchOption.AllDirectories)) + { + if (!file.EndsWith(".yml", StringComparison.OrdinalIgnoreCase) && + !file.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string relative = Infrastructure.RepositoryRoot.ToRelative(file); + scanned.Add(relative); + if (File.ReadAllText(file).Contains("eng/qualification", StringComparison.OrdinalIgnoreCase)) + { + offenders.Add(relative); + } + } + + Assert.Contains(scanned, static file => file.StartsWith(".github/workflows/", StringComparison.Ordinal)); + Assert.True(offenders.Count == 0, + "The local runner starts Cheat Engine and must never run in CI: " + string.Join(", ", offenders)); + } + + [Fact] + public void Runner_guard_is_the_first_statement_and_the_script_runs_in_strict_mode() + { + JsonElement facts = RunJson($$""" + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile({{PowerShellProcess.Quote(QualificationDocuments.Absolute(RunnerScript))}}, [ref] $null, [ref] $errors) + $statements = @($ast.EndBlock.Statements) + [ordered]@{ + parseErrors = @($errors).Count + requiredVersion = "$($ast.ScriptRequirements.RequiredPSVersion)" + supportsShouldProcess = $ast.ParamBlock.Attributes.Extent.Text -join ' ' + firstStatement = $statements[0].Extent.Text + strictMode = @($statements | Where-Object { $_.Extent.Text -eq 'Set-StrictMode -Version Latest' }).Count + stopOnError = @($statements | Where-Object { $_.Extent.Text -eq '$ErrorActionPreference = ''Stop''' }).Count + } | ConvertTo-Json -Compress + """); + + Assert.Equal(0, facts.GetProperty("parseErrors").GetInt32()); + Assert.Equal("7.4", facts.GetProperty("requiredVersion").GetString()); + Assert.Contains("SupportsShouldProcess", facts.GetProperty("supportsShouldProcess").GetString(), StringComparison.Ordinal); + string first = facts.GetProperty("firstStatement").GetString()!; + foreach (string marker in CiMarkers) + { + Assert.Contains("'" + marker + "'", first, StringComparison.Ordinal); + } + + Assert.Contains("exit 3", first, StringComparison.Ordinal); + Assert.Equal(1, facts.GetProperty("strictMode").GetInt32()); + Assert.Equal(1, facts.GetProperty("stopOnError").GetInt32()); + } + + [Fact] + public void Runner_never_writes_to_the_Cheat_Engine_source_directory() + { + JsonElement report = RunJson(WriteTargetAnalysis(QualificationDocuments.Absolute(RunnerScript))); + + Assert.True(report.GetProperty("reads").GetInt32() >= 3, "The analysis found no read of $CheatEnginePath."); + Assert.True(report.GetProperty("violations").GetArrayLength() == 0, report.GetProperty("violations").GetRawText()); + } + + [Fact] + public void Receipt_builder_produces_a_schema_valid_receipt_from_a_recorded_event_log() + { + JsonElement result = RunJson(ModuleImport + $$""" + $plan = Get-Content -LiteralPath {{PowerShellProcess.Quote(QualificationDocuments.Absolute(Scenarios))}} -Raw | ConvertFrom-Json -Depth 64 + $scenario = @($plan.scenarios | Where-Object id -eq 'Q04')[0] + $status = [ordered]@{ schema = 'ce77-live-probe-status-v1'; bootstrap = [ordered]@{ calls = 1; opaqueSecondInt = 0; pluginHostLastInitRecordArgument = 0; interpretation = 'none' } } | ConvertTo-Json -Compress + $lines = @( + [ordered]@{ tMs = 0; source = 'Runner'; kind = 'TargetReady'; message = '{"pid":4242}' } + [ordered]@{ tMs = 2100; source = 'Driver'; kind = 'StepResult'; message = (@{ step = 1; id = 'open'; action = 'openTarget'; ok = $true; values = @(@{ type = 'boolean'; value = $true }) } | ConvertTo-Json -Compress -Depth 8) } + [ordered]@{ tMs = 2300; source = 'Driver'; kind = 'StepResult'; message = (@{ step = 2; id = 'load'; action = 'loadPlugin'; ok = $true; values = @(@{ type = 'integer'; value = 0 }) } | ConvertTo-Json -Compress -Depth 8) } + [ordered]@{ tMs = 2400; source = 'Driver'; kind = 'StepResult'; message = (@{ step = 3; id = 'status'; action = 'call'; ok = $true; values = @(@{ type = 'string'; value = $status }) } | ConvertTo-Json -Compress -Depth 8) } + ) + $redacted = ConvertTo-RedactedSessionEvent -Raw @($lines | ForEach-Object { [pscustomobject] $_ }) -Map (Get-RedactionMap -Paths ([ordered]@{ '' = 'C:\lab\work' })) -OffsetMs 0 + $outcome = Resolve-QualificationOutcome -Scenario $scenario -Steps $redacted.steps -Answers ([ordered]@{}) + $lines = $redacted.entries + $started = [datetimeoffset]::new(2026, 9, 24, 10, 15, 30, [timespan]::Zero) + $nupkg = 'bfa967cc650ad859e5fd3164b53ae2081b6165fd5f2fa16af08b89621dfb70a4' + $receiptId = Get-QualificationReceiptId -StartedUtc $started -QualificationId 'Q04' -NupkgSha256 $nupkg + $eventText = ConvertTo-QualificationJson -InputObject ([ordered]@{ schema = 'cheatengine-qualification-events/v0'; receiptId = $receiptId; events = @($lines) }) + $receipt = ConvertTo-QualificationReceipt -Context ([ordered]@{ + QualificationId = 'Q04'; Level = 'C3'; ProfileId = 'ce-7.7.0.10621-x64-managed-hostfxr'; Operator = 'AriusII'; LoadRoute = 'LuaLoadPlugin' + StartedUtc = $started; FinishedUtc = $started.AddSeconds(11); CreatedUtc = $started.AddSeconds(12); CeStartMs = 2000; Indicative = $false + Repository = [ordered]@{ name = 'CheatEngineNet/CheatEngine.SDK'; treeHash = '40d7d7f741856c7e372e94bbd8532b06651eff5b'; commit = 'e77fb34c1f4e9c0e9d0e4a4a3b6c7d8e9f0a1b2c'; pullRequest = [ordered]@{ number = 86; headSha = 'e77fb34c1f4e9c0e9d0e4a4a3b6c7d8e9f0a1b2c' } } + Runner = [ordered]@{ script = 'eng/qualification/Invoke-LocalQualification.ps1'; scriptSha256 = ('0' * 64); sourceRepository = 'CheatEngineNet/CheatEngine.SDK'; sourceCommit = 'e77fb34c1f4e9c0e9d0e4a4a3b6c7d8e9f0a1b2c'; mutex = 'Global\ce-lab' } + Package = [ordered]@{ id = 'CheatEngine.SDK'; version = '2.0.0-alpha.0.12'; nupkgSha256 = $nupkg; contentHashSha512 = 'DOHZH8TYAm/L/qVXbpPlnoHh34r0GPB9erQYfBy0gyG84KP2iPVW6tFiuSX89K825zxz2u/S21JcFADMQVaUKw=='; source = 'CiArtifact'; ciRunUrl = 'https://github.com/CheatEngineNet/CheatEngine.SDK/actions/runs/1234567890' } + Host = [ordered]@{ ceExeName = 'cheatengine-x86_64.exe'; ceExeSha256 = '9727076da50924e4a097b49a02155e4b34759269c3017ff31375364b8826eb4d'; ceFileVersion = '7.7.0.10621'; luaDllSha256 = 'c95dcdfa0f60f97b43d970d77fd1bb907af4de04b500a3c89a99600b20b35bd2'; runtimeconfigSha256 = '68f5d81c0a17cc5bdac40bb3d5d88a624f4d31b414f7195ad847d57b0126ac2b'; autorunSha256 = ('1' * 64); sandboxCopy = $true; osVersion = 'Microsoft Windows NT 10.0.26200.0'; dotnetRuntimes = @('Microsoft.NETCore.App 10.0.12') } + Bridge = [ordered]@{ sha256 = '889dc4c231d182f9b7baa9e29880555aad949f42023dde232fe327542c3c5387'; sourceFingerprint = '3342be23f88976d9209a24bc0d8b9db512482a24d8db90381a836ea4f5595a56:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8' } + Bundles = @([ordered]@{ name = 'LiveProbe'; manifestSha256 = ('2' * 64); files = @([ordered]@{ path = 'CheatEngine.SDK.LiveProbe.dll'; sha256 = ('3' * 64) }) }) + Target = [ordered]@{ kind = 'QualificationTarget'; arch = 'x64'; sha256 = ('4' * 64) } + Registry = [ordered]@{ key = 'HKCU\Software\Cheat Engine'; exportBeforeSha256 = ('5' * 64); exportAfterSha256 = ('5' * 64); restored = $false; diff = [ordered]@{ added = 0; removed = 0; changed = 0; valueNames = @() } } + Preconditions = @('LiveProbe loaded from the exact CI package.'); Operation = 'Read the raw integer.'; Expected = 'Recorded without interpretation.' + Outcome = $outcome; EventLogSha256 = (Get-QualificationTextSha256 -Text $eventText); Redactions = @('', '') + }) + [ordered]@{ receipt = (ConvertTo-QualificationJson -InputObject $receipt); events = $eventText } | ConvertTo-Json -Compress -Depth 4 + """); + + JsonElement receipt = QualificationDocuments.ParseJson(result.GetProperty("receipt").GetString()!); + string eventText = result.GetProperty("events").GetString()!; + JsonElement supportProfile = QualificationDocuments.LoadJson(QualificationDocuments.SupportProfilePath); + + IReadOnlyList receiptErrors = ReceiptRules.Validate(receipt, supportProfile); + IReadOnlyList eventErrors = ReceiptRules.ValidateEventLog(QualificationDocuments.ParseJson(eventText), + "R-20260924T101530Z-Q04-bfa967cc"); + + Assert.True(receiptErrors.Count == 0, string.Join(Environment.NewLine, receiptErrors)); + Assert.True(eventErrors.Count == 0, string.Join(Environment.NewLine, eventErrors)); + Assert.Equal("R-20260924T101530Z-Q04-bfa967cc", receipt.GetProperty("receiptId").GetString()); + Assert.Equal("Passed", receipt.GetProperty("status").GetString()); + Assert.Equal("Functional", receipt.GetProperty("passKind").GetString()); + Assert.Equal(QualificationDocuments.Sha256OfNormalizedText(eventText), + receipt.GetProperty("eventLog").GetProperty("sha256").GetString()); + Assert.Contains("observed status:bootstrap.opaqueSecondInt = 0", receipt.GetProperty("observed").GetString(), + StringComparison.Ordinal); + } + + [Fact] + public void Redaction_removes_user_paths_and_keeps_scenario_values() + { + const string Root = @"C:\Users\alice\AppData\Local\CheatEngineNet\qualification"; + string text = string.Join(" | ", + Root + @"\sandbox\cheatengine-x86_64.exe", + (Root + @"\runs\1\bundles\LiveProbe\CheatEngine.SDK.LiveProbe.dll").Replace('\\', '/'), + "{\"pluginAssemblyLocation\":\"" + (Root + @"\runs\1\bundles\LiveProbe\CheatEngine.SDK.LiveProbe.dll").Replace(@"\", @"\\") + "\"}", + @"C:\Users\alice\.nuget\packages\other.dll", + "host LAB-PC-07 user alice", + "\"opaqueSecondInt\":0,\"pid\":4242,\"imageBase\":\"0x7FF6A0B40000\""); + + JsonElement result = RunJson(ModuleImport + $$""" + $map = Get-RedactionMap -Paths ([ordered]@{ '' = {{PowerShellProcess.Quote(Root + @"\sandbox")}}; '' = {{PowerShellProcess.Quote(Root)}}; '' = {{PowerShellProcess.Quote(Root + @"\runs\1\bundles\LiveProbe")}} }) -UserName 'alice' -MachineName 'LAB-PC-07' + [ordered]@{ text = (ConvertTo-RedactedText -Text {{PowerShellProcess.Quote(text)}} -Map $map) } | ConvertTo-Json -Compress + """); + string redacted = result.GetProperty("text").GetString()!; + + Assert.Contains("\\cheatengine-x86_64.exe", redacted, StringComparison.Ordinal); + Assert.Contains("/CheatEngine.SDK.LiveProbe.dll", redacted, StringComparison.Ordinal); + Assert.Contains("\"pluginAssemblyLocation\":\"\\\\CheatEngine.SDK.LiveProbe.dll\"", redacted, + StringComparison.Ordinal); + Assert.Contains("", redacted, StringComparison.Ordinal); + Assert.Contains("host user ", redacted, StringComparison.Ordinal); + Assert.DoesNotContain("alice", redacted, StringComparison.OrdinalIgnoreCase); + Assert.False(TextRules.ContainsAbsoluteLocalPath(redacted), redacted); + Assert.Contains("\"opaqueSecondInt\":0,\"pid\":4242,\"imageBase\":\"0x7FF6A0B40000\"", redacted, + StringComparison.Ordinal); + } + + [Fact] + public void Registry_diff_reports_value_names_only() + { + const string Before = """ + Windows Registry Editor Version 5.00 + + [HKEY_CURRENT_USER\Software\Cheat Engine] + "First Time User"=dword:00000000 + "ceshare secret"="token-4f1d9a" + "Long"=hex:01,02,\ + 03,04 + + [HKEY_CURRENT_USER\Software\Cheat Engine\Plugins64] + """; + const string After = """ + Windows Registry Editor Version 5.00 + + [HKEY_CURRENT_USER\Software\Cheat Engine] + "First Time User"=dword:00000001 + "Long"=hex:01,02,\ + 03,04 + "Added"="private-value-77" + + [HKEY_CURRENT_USER\Software\Cheat Engine\Plugins64] + "0"="C:\\sandbox\\plugin.dll" + + [HKEY_CURRENT_USER\Software\Cheat Engine\New Key] + """; + + JsonElement diff = RunJson(ModuleImport + $$""" + $before = ConvertFrom-RegistryExport -Text {{PowerShellProcess.Quote(Before)}} + $after = ConvertFrom-RegistryExport -Text {{PowerShellProcess.Quote(After)}} + Compare-RegistrySnapshot -Before $before -After $after -RootKey 'HKEY_CURRENT_USER\Software\Cheat Engine' | ConvertTo-Json -Compress + """); + string text = diff.GetRawText(); + + Assert.Equal(3, diff.GetProperty("added").GetInt32()); + Assert.Equal(1, diff.GetProperty("removed").GetInt32()); + Assert.Equal(1, diff.GetProperty("changed").GetInt32()); + Assert.Equal(["Added", "First Time User", "New Key\\", "Plugins64\\0", "ceshare secret"], + diff.GetProperty("valueNames").EnumerateArray().Select(static name => name.GetString()!).Order(StringComparer.Ordinal), StringComparer.Ordinal); + foreach (string data in (string[]) ["token-4f1d9a", "private-value-77", "plugin.dll", "dword", "hex:"]) + { + Assert.DoesNotContain(data, text, StringComparison.Ordinal); + } + } + + [Fact] + public void Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry() + { + string bundle = Path.Combine(Path.GetTempPath(), "cesdk-bundle-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(bundle); + try + { + File.WriteAllBytes(Path.Combine(bundle, "Plugin.dll"), [0x4D, 0x5A, 0x00, 0x01]); + foreach (string assembly in (string[]) ["Abi", "Annotations", "Engine", "Hosting", "Lua", "Lua.Interop"]) + { + File.WriteAllText(Path.Combine(bundle, "CheatEngine.SDK." + assembly + ".dll"), string.Empty); + } + + File.WriteAllText(Path.Combine(bundle, "Plugin.deps.json"), + """{ "libraries": { "CheatEngine.SDK.Hosting/1.0.0": { "type": "project", "path": "D:/work/libs/Hosting" } } }"""); + File.WriteAllText(Path.Combine(bundle, "Plugin.runtimeconfig.json"), "{}"); + string packagedBridge = new('a', 64); + + JsonElement missingBridge = Closure(bundle, packagedBridge); + File.WriteAllText(Path.Combine(bundle, "cheatengine-sdk-lua-bridge.dll"), "not the packaged bridge"); + JsonElement otherBridge = Closure(bundle, packagedBridge); + + string[] first = [.. missingBridge.EnumerateArray().Select(static problem => problem.GetString()!)]; + string[] second = [.. otherBridge.EnumerateArray().Select(static problem => problem.GetString()!)]; + Assert.Contains(first, static problem => problem.Contains("bridge", StringComparison.Ordinal) && problem.Contains("missing", StringComparison.Ordinal)); + Assert.Contains(first, static problem => problem.Contains("workspace project entry", StringComparison.Ordinal)); + Assert.Contains(first, static problem => problem.Contains("absolute path", StringComparison.Ordinal)); + Assert.Contains(first, static problem => problem.Contains("CESDK.CESDK.CEPluginInitialize", StringComparison.Ordinal)); + Assert.Contains(second, static problem => problem.Contains("differs from the package", StringComparison.Ordinal)); + } + finally + { + Directory.Delete(bundle, true); + } + } + + [Fact] + public void Every_Checkpoint_B_scenario_exists_and_cites_harness_commands_that_exist() + { + JsonElement plan = QualificationDocuments.LoadJson(Scenarios); + QualificationMatrix matrix = QualificationMatrix.Read(QualificationDocuments.LoadJson(QualificationDocuments.MatrixPath)); + HashSet commands = HarnessLuaFunctions(); + List ids = []; + List problems = []; + foreach (JsonElement scenario in plan.GetProperty("scenarios").EnumerateArray()) + { + string id = scenario.GetProperty("id").GetString()!; + ids.Add(id); + string level = scenario.GetProperty("level").GetString()!; + if (matrix.Find(id) is not { } row || !row.Levels.ContainsKey(level)) + { + problems.Add($"{id}: the matrix has no {level} cell for it."); + continue; + } + + string support = scenario.GetProperty("support").GetString()!; + if (support is "Manual" or "NotApplicable") + { + if (!scenario.TryGetProperty("reason", out JsonElement reason) || string.IsNullOrWhiteSpace(reason.GetString())) + { + problems.Add($"{id}: a {support} scenario states its reason."); + } + + if (string.Equals(support, "NotApplicable", StringComparison.Ordinal) && + !string.Equals(row.Levels[level].Status, "NotApplicable", StringComparison.Ordinal)) + { + problems.Add($"{id}: scenarios.json says NotApplicable but the matrix cell is {row.Levels[level].Status}."); + } + + continue; + } + + problems.AddRange(CheckSteps(id, scenario, commands)); + } + + Assert.Equal(CheckpointB.Order(StringComparer.Ordinal), ids.Order(StringComparer.Ordinal)); + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + [Fact] + public void Driver_templates_are_valid_Lua() + { + JsonElement drivers = RunJson(ModuleImport + $$""" + $template = Get-Content -LiteralPath {{PowerShellProcess.Quote(QualificationDocuments.Absolute(DriverTemplate))}} -Raw + $plan = Get-Content -LiteralPath {{PowerShellProcess.Quote(QualificationDocuments.Absolute(Scenarios))}} -Raw | ConvertFrom-Json -Depth 64 + $result = foreach ($scenario in $plan.scenarios | Where-Object { $_.PSObject.Properties.Name -contains 'steps' }) { + $values = [ordered]@{ + RUN_ID = '20260924T101530Z'; EVENTS_PATH = 'W:/run/events.jsonl'; PROGRESS_PATH = 'W:/run/progress.txt' + HANDSHAKE_DIR = 'W:/run/handshake'; DONE_PATH = 'W:/run/done.txt'; STEPS = @($scenario.steps) + BUNDLES = [ordered]@{ LiveProbe = 'W:/bundles/LiveProbe/CheatEngine.SDK.LiveProbe.dll'; CoexistenceSharedA = 'W:/a.dll' } + TARGETS = [ordered]@{ x64 = 4242 } + } + [ordered]@{ id = $scenario.id; text = (Expand-QualificationDriver -Template $template -Values $values) } + } + ConvertTo-Json -InputObject @($result) -Compress -Depth 4 + """); + + List problems = []; + foreach (JsonElement driver in drivers.EnumerateArray()) + { + string id = driver.GetProperty("id").GetString()!; + string text = driver.GetProperty("text").GetString()!; + if (Regex.IsMatch(text, "__[A-Z][A-Z_]*__", RegexOptions.None, RegexTimeout)) + { + problems.Add($"{id}: a placeholder was not substituted."); + } + + string? error = LuaSyntaxChecker.Compile(text, "zz_cesdk_qualification_" + id); + if (error is not null) + { + problems.Add($"{id}: {error}"); + } + } + + Assert.True(drivers.GetArrayLength() >= 14, "Too few drivers were generated."); + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + Assert.Null(LuaSyntaxChecker.Compile(QualificationDocuments.ReadNormalizedText(DriverTemplate) + .Replace("__STEPS__", "{}", StringComparison.Ordinal) + .Replace("__BUNDLES__", "{}", StringComparison.Ordinal) + .Replace("__TARGETS__", "{}", StringComparison.Ordinal) + .Replace("__RUN_ID__", "'r'", StringComparison.Ordinal) + .Replace("__EVENTS_PATH__", "'e'", StringComparison.Ordinal) + .Replace("__PROGRESS_PATH__", "'p'", StringComparison.Ordinal) + .Replace("__HANDSHAKE_DIR__", "'h'", StringComparison.Ordinal) + .Replace("__DONE_PATH__", "'d'", StringComparison.Ordinal), "template")); + Assert.NotNull(LuaSyntaxChecker.Compile("local x = = 1", "broken")); + } + + private static IEnumerable CheckSteps(string id, JsonElement scenario, HashSet commands) + { + HashSet stepIds = new(StringComparer.Ordinal); + foreach (JsonElement step in scenario.GetProperty("steps").EnumerateArray()) + { + string stepId = step.GetProperty("id").GetString()!; + if (!stepIds.Add(stepId)) + { + yield return $"{id}: step id '{stepId}' repeats."; + } + + string kind = step.GetProperty("kind").GetString()!; + if (string.Equals(kind, "Operator", StringComparison.Ordinal)) + { + if (string.IsNullOrWhiteSpace(step.GetProperty("instruction").GetString())) + { + yield return $"{id}.{stepId}: an operator step needs an instruction."; + } + + continue; + } + + string action = step.GetProperty("action").GetString()!; + if (string.Equals(action, "call", StringComparison.Ordinal) && + !commands.Contains(step.GetProperty("function").GetString()!)) + { + yield return $"{id}.{stepId}: no harness declares the Lua function '{step.GetProperty("function").GetString()}'."; + } + else if (action is not ("call" or "loadPlugin" or "openTarget" or "wait")) + { + yield return $"{id}.{stepId}: unknown action '{action}'."; + } + } + + foreach (JsonElement check in scenario.GetProperty("passRule").GetProperty("checks").EnumerateArray()) + { + if (!stepIds.Contains(check.GetProperty("step").GetString()!)) + { + yield return $"{id}: a pass-rule check names the unknown step '{check.GetProperty("step").GetString()}'."; + } + } + } + + private static HashSet HarnessLuaFunctions() + { + HashSet names = new(StringComparer.Ordinal); + foreach (string directory in (string[]) + ["tests/CheatEngine.SDK.LiveProbe", "tests/CheatEngine.SDK.LivePlugin", "tests/CheatEngine.SDK.LivePlugin.Coexistence"]) + { + foreach (string file in Directory.EnumerateFiles(QualificationDocuments.Absolute(directory), "*.cs", + SearchOption.AllDirectories)) + { + string source = File.ReadAllText(file); + foreach (Match match in Regex.Matches(source, + "\\[LuaFunction\\(\"(?[A-Za-z_][A-Za-z0-9_]*)\"\\)\\]|TryRegister\\([^,]+,\\s*\"(?[A-Za-z_][A-Za-z0-9_]*)\"u8\\)", + RegexOptions.None, RegexTimeout)) + { + names.Add(match.Groups["name"].Value); + } + } + } + + return names; + } + + private static JsonElement Closure(string bundle, string packagedBridge) + { + return RunJson(ModuleImport + + $"ConvertTo-Json -Compress -InputObject @(Test-QualificationBundleClosure -BundleDirectory {PowerShellProcess.Quote(bundle)} -PluginFileName 'Plugin.dll' -PackagedBridgeSha256 '{packagedBridge}')"); + } + + private static JsonElement RunJson(string script) + { + PowerShellProcess.Result result = PowerShellProcess.RunCommand(script, null, TestContext.Current.CancellationToken); + Assert.True(result.ExitCode == 0, result.Transcript); + string json = result.Output.Trim(); + Assert.False(string.IsNullOrEmpty(json), result.Transcript); + return QualificationDocuments.ParseJson(json); + } + + // Scope-aware analysis of the runner: inside each function and at script level, every variable derived from + // $CheatEnginePath is tracked, and no write (cmdlet, .NET file API, robocopy destination) may target one of them. + private static string WriteTargetAnalysis(string scriptPath) + { + return $$""" + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile({{PowerShellProcess.Quote(scriptPath)}}, [ref] $null, [ref] $errors) + function Get-Names($node) { if ($null -eq $node) { return @() }; @($node.FindAll({ $args[0] -is [System.Management.Automation.Language.VariableExpressionAst] }, $true) | ForEach-Object { $_.VariablePath.UserPath }) } + $writeCommands = 'Set-Content','Add-Content','Out-File','New-Item','Remove-Item','Rename-Item','Move-Item','Clear-Content','Set-Item','Expand-Archive' + $violations = [System.Collections.Generic.List[string]]::new() + $reads = 0 + $scopes = [ordered]@{} + foreach ($node in $ast.FindAll({ $true }, $true)) { + $key = 'script' + for ($parent = $node.Parent; $null -ne $parent; $parent = $parent.Parent) { + if ($parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { $key = "$($parent.Name)@$($parent.Extent.StartOffset)"; break } + } + if (-not $scopes.Contains($key)) { $scopes[$key] = [System.Collections.Generic.List[object]]::new() } + $scopes[$key].Add($node) + } + foreach ($nodes in $scopes.Values) { + $aliases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + [void] $aliases.Add('CheatEnginePath') + do { + $count = $aliases.Count + foreach ($assignment in $nodes | Where-Object { $_ -is [System.Management.Automation.Language.AssignmentStatementAst] }) { + if ((Get-Names $assignment.Right | Where-Object { $aliases.Contains($_) }) -and $assignment.Left -is [System.Management.Automation.Language.VariableExpressionAst]) { [void] $aliases.Add($assignment.Left.VariablePath.UserPath) } + } + foreach ($loop in $nodes | Where-Object { $_ -is [System.Management.Automation.Language.ForEachStatementAst] }) { + if (Get-Names $loop.Condition | Where-Object { $aliases.Contains($_) }) { [void] $aliases.Add($loop.Variable.VariablePath.UserPath) } + } + } while ($aliases.Count -gt $count) + $reads += @($nodes | Where-Object { $_ -is [System.Management.Automation.Language.VariableExpressionAst] -and $_.VariablePath.UserPath -eq 'CheatEnginePath' }).Count + foreach ($command in $nodes | Where-Object { $_ -is [System.Management.Automation.Language.CommandAst] }) { + $name = $command.GetCommandName() + $elements = @($command.CommandElements) + if ($name -in $writeCommands) { + if (Get-Names $command | Where-Object { $aliases.Contains($_) }) { $violations.Add("line $($command.Extent.StartLineNumber): $name targets the installation") } + } + elseif ($name -eq 'Copy-Item') { + for ($i = 0; $i + 1 -lt $elements.Count; $i++) { + if ($elements[$i] -is [System.Management.Automation.Language.CommandParameterAst] -and $elements[$i].ParameterName -eq 'Destination' -and (Get-Names $elements[$i + 1] | Where-Object { $aliases.Contains($_) })) { $violations.Add("line $($command.Extent.StartLineNumber): Copy-Item destination is the installation") } + } + } + elseif ($name -eq 'Invoke-Native' -and $command.Extent.Text -match 'robocopy') { + $items = @(@($command.FindAll({ $args[0] -is [System.Management.Automation.Language.ArrayLiteralAst] }, $true))[0].Elements) + for ($i = 1; $i -lt $items.Count; $i++) { if (Get-Names $items[$i] | Where-Object { $aliases.Contains($_) }) { $violations.Add("line $($command.Extent.StartLineNumber): robocopy destination is the installation") } } + } + } + foreach ($call in $nodes | Where-Object { $_ -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $_.Static }) { + $type = $call.Expression.Extent.Text + $member = $call.Member.Extent.Text + if ($type -match 'IO\.(File|Directory)\]|ZipFileExtensions' -and $member -match '^(Write|Append|Delete|Move|Copy|Create|Replace|Set|Extract)') { + $arguments = @($call.Arguments) + $target = if ($member -match '^(Move|Copy|Extract)') { $arguments[1] } else { $arguments[0] } + if (Get-Names $target | Where-Object { $aliases.Contains($_) }) { $violations.Add("line $($call.Extent.StartLineNumber): $type::$member writes into the installation") } + } + } + } + [ordered]@{ reads = $reads; violations = @($violations) } | ConvertTo-Json -Compress + """; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/LuaSyntaxChecker.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/LuaSyntaxChecker.cs new file mode 100644 index 00000000..c159856a --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/LuaSyntaxChecker.cs @@ -0,0 +1,72 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// +/// Compiles Lua source with the committed Cheat Engine Lua 5.3 module (native/cheat-engine/lua53-64.dll) without +/// running it: luaL_newstate, luaL_loadbufferx in text mode, lua_close. Delegates over the +/// exports keep the test project free of unsafe code and of any project reference. +/// +internal static class LuaSyntaxChecker +{ + private const int LuaOk = 0; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate nint NewState(); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int LoadBufferX(nint state, byte[] buffer, nuint size, byte[] name, byte[] mode); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate nint ToLString(nint state, int index, nint length); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Close(nint state); + + /// Returns when compiles, otherwise Lua's message. + internal static string? Compile(string source, string chunkName) + { + nint library = NativeLibrary.Load(QualificationDocuments.Absolute("native/cheat-engine/lua53-64.dll")); + try + { + NewState newState = Export(library, "luaL_newstate"); + LoadBufferX load = Export(library, "luaL_loadbufferx"); + ToLString toString = Export(library, "lua_tolstring"); + Close close = Export(library, "lua_close"); + nint state = newState(); + if (state == 0) + { + throw new InvalidOperationException("luaL_newstate returned no state."); + } + + try + { + byte[] bytes = Encoding.UTF8.GetBytes(source); + int status = load(state, bytes, (nuint) bytes.Length, Terminated("=" + chunkName), Terminated("t")); + return status == LuaOk + ? null + : $"luaL_loadbufferx status {status}: {Marshal.PtrToStringUTF8(toString(state, -1, 0))}"; + } + finally + { + close(state); + } + } + finally + { + NativeLibrary.Free(library); + } + } + + private static T Export(nint library, string name) + where T : Delegate + { + return Marshal.GetDelegateForFunctionPointer(NativeLibrary.GetExport(library, name)); + } + + private static byte[] Terminated(string text) + { + return Encoding.UTF8.GetBytes(text + "\0"); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/PowerShellProcess.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/PowerShellProcess.cs new file mode 100644 index 00000000..e81dd6ef --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/PowerShellProcess.cs @@ -0,0 +1,110 @@ +using System.Diagnostics; +using System.Text; + +namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; + +/// +/// Runs PowerShell 7 (pwsh) for the tests of the repository scripts. pwsh is resolved from PATH; +/// when it is absent the test fails instead of being skipped, because CI runs with --fail-skips on and a +/// silently skipped script test would hide a broken gate. It never starts Cheat Engine. +/// +internal static class PowerShellProcess +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60); + + internal static string Executable { get; } = Resolve(); + + /// Runs with -NoProfile -NonInteractive -EncodedCommand. + internal static Result RunCommand(string script, IReadOnlyDictionary? environment, + CancellationToken cancellationToken) + { + string encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + return Run(["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], environment, cancellationToken); + } + + /// Runs a script file with -NoProfile -NonInteractive -File and the given arguments. + internal static Result RunFile(string file, IReadOnlyList arguments, + IReadOnlyDictionary? environment, CancellationToken cancellationToken) + { + return Run(["-NoProfile", "-NonInteractive", "-File", file, .. arguments], environment, cancellationToken); + } + + /// A PowerShell string literal (single-quoted) for . + internal static string Quote(string value) + { + return "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + } + + private static Result Run(IEnumerable arguments, IReadOnlyDictionary? environment, + CancellationToken cancellationToken) + { + ProcessStartInfo start = new(Executable) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true, + WorkingDirectory = Infrastructure.RepositoryRoot.Path + }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + if (environment is not null) + { + foreach ((string name, string? value) in environment) + { + if (value is null) + { + start.Environment.Remove(name); + } + else + { + start.Environment[name] = value; + } + } + } + + using Process process = Process.Start(start) ?? throw new InvalidOperationException("pwsh did not start."); + Task output = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task error = process.StandardError.ReadToEndAsync(cancellationToken); + if (!process.WaitForExit(Timeout)) + { + process.Kill(true); + throw new TimeoutException($"pwsh did not exit within {Timeout.TotalSeconds} seconds."); + } + + process.WaitForExit(); + return new Result(process.ExitCode, output.GetAwaiter().GetResult(), error.GetAwaiter().GetResult()); + } + + private static string Resolve() + { + string[] names = OperatingSystem.IsWindows() ? ["pwsh.exe"] : ["pwsh"]; + foreach (string directory in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split( + Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + foreach (string name in names) + { + string candidate = Path.Combine(directory.Trim('"'), name); + if (File.Exists(candidate)) + { + return candidate; + } + } + } + + throw new InvalidOperationException( + "PowerShell 7 (pwsh) is not on PATH. The repository script tests require it; install PowerShell 7.4 or later."); + } + + /// The exit code and the captured output of one run. + internal sealed record Result(int ExitCode, string Output, string Error) + { + /// Everything the process printed, for assertion messages. + internal string Transcript => $"exit {ExitCode}{Environment.NewLine}{Output}{Environment.NewLine}{Error}"; + } +} From 8a03bf015596c1b7f2d0fa9a4c2fe89e79fbbcbd Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 02:28:09 +0200 Subject: [PATCH 029/199] Map unhandled qualification runner errors to exit code 6 A failing harness build, a missing tool or a target that never becomes ready threw out of the runner with PowerShell's generic exit code 1, which the documented exit-code table does not know. A script-level trap now reports the message and exits 6 ("Cheat Engine, build, driver or any other unhandled failure"). The trap only changes the exit code: typed catch blocks still handle their exceptions, explicit exit codes (2 to 5 and 7) pass through, and every finally block still runs, so the target is stopped, the driver, manifest and fault switch are removed, HKCU is compared and restored and Global\ce-lab is released. The guard stays the first statement because traps are not statements of the script's end block. The AST test now also requires exactly one trap and that it exits 6. --- eng/qualification/Invoke-LocalQualification.ps1 | 12 ++++++++++-- .../Qualification/LocalQualificationRunnerTests.cs | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 index 50905f76..9e651939 100644 --- a/eng/qualification/Invoke-LocalQualification.ps1 +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -16,8 +16,8 @@ eng/qualification/README.md and docs/qualification/local-protocol.md. Exit codes: 0 success; 2 invalid arguments; 3 CI environment; 4 host or profile mismatch; 5 unsafe environment; - 6 Cheat Engine, build or driver failure; 7 HKCU restore or verification failure (critical: the restore command and - the backup path are printed). + 6 Cheat Engine, build, driver or any other unhandled failure; 7 HKCU restore or verification failure (critical: the + restore command and the backup path are printed). .PARAMETER Scenario Scenario ids of eng/qualification/scenarios.json (for example Q04, Q09.a) or CheckpointB for every runnable one. @@ -70,6 +70,14 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $InformationPreference = 'Continue' +# Any error nothing handles (a failing build, a missing tool, a target that never gets ready) ends the run with the +# documented code 6 instead of PowerShell's generic 1. Every finally block still runs first: the target is stopped, +# the driver, manifest and fault switch are removed, HKCU is compared and restored, and Global\ce-lab is released. +trap { + [System.Console]::Error.WriteLine("Invoke-LocalQualification.ps1: $($_.Exception.Message) (exit 6)") + exit 6 +} + Import-Module (Join-Path $PSScriptRoot 'QualificationRunner.psm1') -Force $ProfileId = 'ce-7.7.0.10621-x64-managed-hostfxr' diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs index e519b776..380f77dc 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -91,6 +91,7 @@ public void Runner_guard_is_the_first_statement_and_the_script_runs_in_strict_mo firstStatement = $statements[0].Extent.Text strictMode = @($statements | Where-Object { $_.Extent.Text -eq 'Set-StrictMode -Version Latest' }).Count stopOnError = @($statements | Where-Object { $_.Extent.Text -eq '$ErrorActionPreference = ''Stop''' }).Count + traps = @($ast.EndBlock.Traps | ForEach-Object { $_.Extent.Text }) } | ConvertTo-Json -Compress """); @@ -106,6 +107,10 @@ public void Runner_guard_is_the_first_statement_and_the_script_runs_in_strict_mo Assert.Contains("exit 3", first, StringComparison.Ordinal); Assert.Equal(1, facts.GetProperty("strictMode").GetInt32()); Assert.Equal(1, facts.GetProperty("stopOnError").GetInt32()); + + // An unhandled error maps to the documented exit code 6, not to PowerShell's generic 1. + JsonElement trap = Assert.Single(facts.GetProperty("traps").EnumerateArray()); + Assert.Contains("exit 6", trap.GetString(), StringComparison.Ordinal); } [Fact] From bf2961e131cf45d8e469c0a60d2c855f2dc01403 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 02:28:09 +0200 Subject: [PATCH 030/199] Document the local qualification protocol and runner docs/qualification/local-protocol.md is the operator's side of exact-host evidence: prerequisites, the safety rules the runner enforces (never CI, never elevated, sandbox copy only, HKCU restore on a non-empty difference only, Global\ce-lab, quiet machine, disposable targets), how to download the exact CI package, how to run, one row per Checkpoint B scenario taken from eng/qualification/scenarios.json, the LiveProbe authorization and fault switch, and how receipts are reviewed, published and linked from a matrix cell with the freshness rule the repository tests enforce. eng/qualification/README.md documents the runner itself: its files, the fourteen stages, every parameter, the exit codes, the files it writes under -WorkRoot, the promises and the tests that hold them. Both documents are recreated from the audit, not from the historical documentations/ tree, and carry no local paths. They are added to their solution folders. --- CheatEngine.SDK.slnx | 2 + docs/qualification/local-protocol.md | 133 ++++++++++++++++++++++++++- eng/qualification/README.md | 126 +++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 eng/qualification/README.md diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 8fc8fa10..cfbd0f27 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -41,6 +41,7 @@ + @@ -64,6 +65,7 @@ + diff --git a/docs/qualification/local-protocol.md b/docs/qualification/local-protocol.md index c6f26e94..64c93138 100644 --- a/docs/qualification/local-protocol.md +++ b/docs/qualification/local-protocol.md @@ -2,6 +2,135 @@ > Recreated 2026-09 from the audit, not the historical documentations/ tree. -How an operator runs, records and redacts an exact-host (C3/C4) qualification run and its receipt. +How an operator produces exact-host (C3/C4) evidence for the [qualification matrix](README.md): the prerequisites, the +safety rules, how to obtain the exact package, the procedure of each scenario, and how receipts are reviewed, committed +and linked from the matrix. The runner itself is documented in [`eng/qualification`](../../eng/qualification/README.md). +Nothing in this protocol runs in CI, and a C1/C2 result is never a substitute for it. -Status: placeholder — content arrives with S-QUAL (V1) +## Prerequisites + +| Need | Detail | +|-----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Host | Windows x64 with the installation of profile `ce-7.7.0.10621-x64-managed-hostfxr` ([support profile](support-profile.md)); the runner's preflight verifies every hash. | +| Tools | PowerShell 7.4 or later, the .NET SDK that `global.json` pins, the MSVC build tools Native AOT needs (for the qualification target), git, and the GitHub CLI. | +| Repository | A clean checkout of the tree the package was built from: its `git rev-parse HEAD^{tree}` goes into every receipt. | +| Package | The exact CI package of that tree (below), or the nuget.org file for a released version. A local pack is only good for a smoke run. | +| Operator | Your GitHub handle (`-Operator`), recorded in every receipt, and about one hour of attention for the guided scenarios. | + +## Safety rules + +- **Never in CI.** The runner exits with code 3 when `CI`, `GITHUB_ACTIONS` or `TF_BUILD` is set, and no workflow + references it (`LocalQualificationRunnerTests`). +- **Never elevated.** Run PowerShell as your normal user; Cheat Engine then starts as the invoking user. +- **Sandbox only.** The installation under `%ProgramFiles%\Cheat Engine` is read and copied, never written, launched or + configured. The runner mirrors it into `\sandbox`, compares every file by SHA-256, and starts + `cheatengine-x86_64.exe` from there; never the launcher or the SSE4-AVX2 executable. +- **Registry.** Every copy of Cheat Engine shares `HKCU\Software\Cheat Engine`. The runner exports it before and after + each scenario, records the difference as counts and value names, and restores it only when the difference is + non-empty. If another Cheat Engine instance runs, stop it first; with `-AllowOtherCheatEngineInstances` the runner never + restores automatically and stops with exit code 7 and the manual restore command instead. +- **One lab at a time.** Runs take the `Global\ce-lab` mutex, so two runners (or a runner and another lab tool that + honours the mutex) never share Cheat Engine. +- **Quiet machine.** No heavy build while timings are recorded. The runner refuses when `dotnet`, `MSBuild` or + `VBCSCompiler` run, unless `-AllowConcurrentLoad` marks the timings indicative. +- **Disposable targets only.** The qualification target, or the installation's `gtutorial-i386.exe` for x86 + scenarios; nothing else is opened. + +## Obtaining the exact package + +1. Find the CI run of the pull request head (or of `main`) whose tree you have checked out. +2. Download its package artifact (name `nuget-package`): + + ```powershell + gh run download --repo CheatEngineNet/CheatEngine.SDK -n nuget-package -D + ``` + +3. The runner reads the id and version from the package's `.nuspec`, never from the file name, and records three + identities in each receipt: the SHA-256 of the `.nupkg`, the NuGet content hash (SHA-512, base64) computed by an + isolated restore, and the CI run URL. It builds every plugin from that package only, with an isolated NuGet packages + folder, so a package already in your global NuGet cache can never be used instead. + +## Running + +Check the host first; this reads and hashes files and starts nothing: + +```powershell +./eng/qualification/Invoke-LocalQualification.ps1 -PreflightOnly -WhatIf +``` + +Then run the scenarios, all of Checkpoint B or a list: + +```powershell +./eng/qualification/Invoke-LocalQualification.ps1 -Scenario CheckpointB -PackagePath \CheatEngine.SDK..nupkg ` + -CiRunUrl https://github.com/CheatEngineNet/CheatEngine.SDK/actions/runs/ -PullRequest -HeadSha -Operator +``` + +Each scenario is one Cheat Engine session. When a scenario needs you, the runner prints `OPERATOR STEP` with the action +to take in Cheat Engine and waits; type your observation (or `y` / `n` when asked) and press Enter. The Lua driver then +continues. A plumbing check with a local pack uses `-Smoke`: it writes `smoke-report.json` and never a receipt. + +## Scenarios + +The plan is [`eng/qualification/scenarios.json`](../../eng/qualification/scenarios.json); preconditions, operation and +expected result come from the [matrix](matrix.json). Every LiveProbe scenario opens the x64 qualification target first +and loads the plugin with `loadPlugin`, so the probe's authorization gate (exact host, short-lived manifest, target +opened in Cheat Engine) is satisfied. + +| Scenario | Level | Harness | Target | Steps | Pass rule | +|----------|-------|-------------------------------|--------|--------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------| +| Q02 | C3 | LiveProbe | x64 | Automated status read; operator reviews the tail-canary record | canary written and operator confirms only the 36 record bytes were written | +| Q03 | C3 | LiveProbe | x64 | Automated status read | records the exports size the host reports; `NotApplicable` (a reduced table cannot be produced) | +| Q04 | C3 | LiveProbe | x64 | Automated status read | raw second bootstrap integer recorded, interpretation `none` | +| Q05 | C3 | LiveProbe | x64 | Status, operator unticks and ticks the plugin, status again, operator confirms the name | new epoch, same plugin assembly, stable name | +| Q05.a | C3 | LiveProbeNonAscii | x64 | Status; operator records how the name is shown and whether the list is intact | plugin loads and the operator confirms nothing is corrupted | +| Q06 | C3 | LiveProbe, fault `OnEnable` | x64 | Load with the fault switch; the runner removes it; operator re-enables | first enable failed and its commands are gone; re-enable succeeds and records `OnEnable@1` | +| Q07 | C3 | LiveProbe | x64 | Operator unticks the plugin while `ce77_live_probe_pump_messages(20)` runs | pump completes and the plugin stays enabled (nested disable refused); operator confirms the action | +| Q08 | C3 | LiveProbe, fault `OnDisable` | x64 | Operator unticks (OnDisable throws), the runner removes the switch, operator re-enables and judges | failure recorded (`OnDisable@1`) and no cleanly-disabled state shown while cleanup had failed | +| Q09.a | C4 | Coexistence A and B, one folder | none | Load both, record identities, operator removes A, B still answers, operator restores A | identities recorded; B works while A is removed; both work again | +| Q09.b | C4 | Coexistence A and B, two folders | none | Same as Q09.a | same | +| Q14 | C3 | LiveProbe | x64 | Automated: `pcall(ce77_live_probe_throw_managed_exception)`, then status | catchable Lua error with the marker text; the next call works | +| Q15 | C3 | LiveProbe | x64 | Callback prepared and called; operator unticks; the kept callback is called again | the kept callback fails with "released" | +| Q17 | C3 | — | — | Manual: no SDK-controlled state replacement can be triggered in the host yet | stays `NotExecuted` | +| Q18 | C3 | LiveProbe | x64 | Snapshot, operator runs `resetLuaState()`, snapshot after; the driver resumes from its progress file | operator confirms the outcome is recorded as unsupported, with no safety claimed | +| Q19 | C3 | LiveProbe | x64 | Worker Lua-state and synchronize observations; operator judges | operator confirms serialization or refusal | +| Q39 | C3 | — | — | None: `NotApplicable` by profile decision CPA-2 | — | +| Q40 | C3 | LiveProbe (clean folder) | x64 | Status and host profile | plugin, Hosting assembly and bridge load from the bundle folder; bridge equals the packaged one | + +A scenario whose operator step was not performed (for example Q07 without unticking) is **Inconclusive**: no receipt is +written and the runner says so. + +## LiveProbe authorization and fault switch + +For every LiveProbe scenario the runner writes the short-lived `ce77-live-probe-v1` manifest (exact host hash, the +target's PID and image hash, `disposable: true`, 30-minute expiry) under the run directory, sets +`CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT` and `CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE` in its own process so that Cheat Engine +inherits them, and deletes the manifest and the variables when the session ends. For Q06 and Q08 it also writes +`liveprobe.fault.json` (`{"schema":"ce77-live-probe-fault-v1","throwIn":"OnEnable"}` or `OnDisable`) next to the plugin +in the bundle folder and removes it at the operator step that re-enables the plugin. See the +[LiveProbe harness](../../tests/CheatEngine.SDK.LiveProbe/README.md). + +## Reviewing, publishing and linking receipts + +1. Receipts are written to `\runs\\receipts\\.json`, each with + `.events.json`. Read the `observed` text and the event log: user and machine names, local paths outside + the placeholders (``, ``, ``, ``, ``, ``, ``, + ``, ``), and raw debug output must not appear. A smoke report flags a user path that escaped + redaction (`unredactedUserPath`). +2. Copy them into the repository with `-PublishReceiptsTo ` (or by hand) as + `docs/qualification/receipts//.json` and `.events.json`. The runner prints the matrix cell update. +3. Update the matching cell of [`matrix.json`](matrix.json) in the same commit: `status` and `passKind` as in the + receipt, `evidenceKind: ObservedHost`, an `evidence` entry `{kind: Receipt, receiptId, path, sha256}` (the SHA-256 of + the receipt after CRLF is normalized to LF), `treeHash`, `nupkgSha256` and `date`. A NotApplicable receipt (Q03) keeps + its justification. +4. Freshness: a receipt is valid for the tree and package it names. A cell whose `treeHash` or `nupkgSha256` differs + from its receipt's needs a `transferJustification` that says why the result still holds; without one the repository + tests refuse the cell. Replace the generated blocks of `README.md` and `support-profile.md` with the text the failing + tests print. +5. `dotnet test --project tests/CheatEngine.SDK.Repository.Tests` validates the receipts, their event logs, the matrix + links and the hashes before the pull request is opened. + +## Never committed + +Cheat Engine or target binaries, the authorization manifest, the fault switch, registry exports (`.reg` files hold +private settings), bundle folders, the driver's raw `driver-events.jsonl`, smoke reports, and raw DebugView output. Only +the redacted receipt and its event log enter the repository. diff --git a/eng/qualification/README.md b/eng/qualification/README.md new file mode 100644 index 00000000..add7f72b --- /dev/null +++ b/eng/qualification/README.md @@ -0,0 +1,126 @@ +# eng/qualification + +The local, operator-attended runner that produces exact-host (C3/C4) receipts for the +[qualification matrix](../../docs/qualification/README.md). The operator's side is the +[local qualification protocol](../../docs/qualification/local-protocol.md). + +## Objective + +Run a qualification scenario on a sandbox copy of the exact Cheat Engine host with plugins built from the exact CI +package, and write a redacted, schema-valid receipt that a reader can check without the operator's machine (audit +analyses/20, ADR-12). The runner never runs in CI and never modifies the Cheat Engine installation or leaves the +operator's Cheat Engine settings changed. + +## How it works + +| File | Content | +|-----------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Invoke-LocalQualification.ps1` | The runner: guard, preflight, mutex, sandbox, bundles, targets, registry, Cheat Engine session, receipts. | +| `QualificationRunner.psm1` | Pure helpers the runner and the tests share: hashing with the LF rule, registry parsing and name-only diff, redaction, event log bounding, receipt id and assembly, Lua literals, bundle closure, pass-rule evaluation. | +| `driver/zz_cesdk_qualification.template.lua` | The autorun Lua driver template: runs one scenario step per timer tick under `pcall`, appends one JSON event per line, resumes after a Lua state reset. | +| `scenarios.json` | The Checkpoint B plan: harnesses, target, steps (Lua or Operator), observed values and a declarative pass rule per scenario. | + +### Stages + +1. **Guard.** The first statement: exits 3 when `CI`, `GITHUB_ACTIONS` or `TF_BUILD` is set, before anything else. +2. **Preflight** (read-only). Hashes `cheatengine-x86_64.exe`, `lua53-64.dll`, `ce.runtimeconfig.json` and `celua.txt`, + reads the file version and PE machine and compares them with [`support-profile.json`](../../docs/qualification/support-profile.json); + refuses an elevated runner, another Cheat Engine instance, build processes and a dirty tree unless allowed; checks + that `dotnet --version` equals `global.json`; records `dotnet --list-runtimes`. +3. **Mutex** `Global\ce-lab` (an abandoned mutex is taken over). +4. **Sandbox.** `robocopy /MIR` of the installation into `\sandbox`, then a SHA-256 comparison of every file; + a stale driver is removed. +5. **Bundles.** For each harness (`LiveProbe`, `LiveProbeNonAscii`, `LivePlugin`, `CoexistenceA`, `CoexistenceB`): a + throw-away consumer project with empty `Directory.Build.*` and `Directory.Packages.props`, a copy of `global.json`, + `Compile` items for the harness sources, a `PackageReference` to the exact package version read from its `.nuspec`, a + `NuGet.Config` with `` and package source mapping, and an isolated `NUGET_PACKAGES`; restored with + `--no-http-cache --force-evaluate`, published, then checked: plugin with `CESDK.CESDK.CEPluginInitialize(nint, int)` + (read with System.Reflection.Metadata), the six SDK assemblies, `.deps.json` without project entries or absolute + paths, `.runtimeconfig.json`, and the bridge equal to the package's `build/native` copy. Q09.a merges A and B into one + folder and refuses a same-named file with different bytes. Each bundle gets `bundle-manifest..json` (every file + with its SHA-256, and the build warnings). +6. **Bridge identity.** SHA-256 of the packaged bridge and its exported source fingerprint. +7. **Targets.** Publishes `tests/CheatEngine.SDK.QualificationTarget` for the needed architectures, starts it and reads + its ready record; writes the LiveProbe authorization manifest and, for fault scenarios, `liveprobe.fault.json`. +8. **HKCU before.** `reg export` of `HKCU\Software\Cheat Engine` into the session folder. +9. **Driver.** Generates `autorun\zz_cesdk_qualification.lua` in the sandbox from the template with the steps, bundle + paths and target PIDs. +10. **Launch.** Starts the sandbox `cheatengine-x86_64.exe`, not elevated, and serves operator steps through handshake + files; the watchdog (`-CeTimeoutSeconds`) kills Cheat Engine and fails the scenario. +11. **Cleanup** (always): stops the target and stray sandbox processes, removes the driver, the manifest, the fault + switch and the environment variables. +12. **HKCU after.** Exports, compares by value names, restores only a non-empty difference with no other Cheat Engine + running, and verifies the restore (exit 7 otherwise, with the manual command and the backup path). +13. **Redaction and receipt.** Replaces the work root, sandbox, bundles, repository, installation, user and machine + names with placeholders, bounds the event log, evaluates the pass rule and writes `.json` and + `.events.json` (receipt id `R---`). +14. **Publication** (`-PublishReceiptsTo`): copies receipts into `docs/qualification/receipts//` and prints the + matrix cell update. The runner never edits `matrix.json`. + +### Parameters + +| Parameter | Meaning | +|------------------------------------|-----------------------------------------------------------------------------------------------------------| +| `-Scenario ` | Scenario ids of `scenarios.json` (`Q04`, `Q09.a`, …) or `CheckpointB` (default) for every runnable one. | +| `-PackagePath ` | The exact `CheatEngine.SDK` `.nupkg`. Required unless `-PreflightOnly`. | +| `-PackageSource CiArtifact\|NuGetOrg` | Where the package came from (default `CiArtifact`). | +| `-CiRunUrl ` | The CI run that produced the artifact; required for `CiArtifact` receipts. | +| `-PullRequest `, `-HeadSha ` | Pull request identity recorded in receipts (both or neither). | +| `-Operator ` | GitHub handle recorded in receipts; required for receipts. | +| `-CheatEnginePath ` | The installation to copy; default `%ProgramFiles%\Cheat Engine`. Read and copied only. | +| `-WorkRoot ` | Default `%LOCALAPPDATA%\CheatEngineNet\qualification`; refused inside a git work tree or below the repository's parent directory. | +| `-CeTimeoutSeconds ` | Watchdog per scenario session (default 900). | +| `-MutexTimeoutMinutes ` | How long to wait for `Global\ce-lab` (default 30). | +| `-PreflightOnly` | Stage 2 only; prints the preflight record and exits 0 when the host matches the profile. | +| `-Smoke` | Plumbing run with a local pack: `smoke-report.json`, never a receipt; a dirty tree is allowed. | +| `-AllowConcurrentLoad` | Run despite build processes; receipts mark the timings indicative. | +| `-AllowOtherCheatEngineInstances` | Run next to another Cheat Engine; HKCU is then never restored automatically. | +| `-PublishReceiptsTo ` | Copy the receipts into that repository's `docs/qualification/receipts`. | +| `-WhatIf`, `-Confirm` | Standard `ShouldProcess` switches; `-PreflightOnly -WhatIf` changes nothing. | + +### Exit codes + +| Code | Meaning | +|------|-------------------------------------------------------------------------| +| 0 | Success (receipts or smoke report written, or preflight passed) | +| 2 | Invalid arguments | +| 3 | A CI environment variable is set | +| 4 | The installation is not the profiled host | +| 5 | Unsafe environment (elevation, other instance, load, dirty tree, SDK version, work root, sandbox copy, mutex) | +| 6 | Cheat Engine, build, driver or any other unhandled failure (for example the watchdog fired or a harness did not build); cleanup, HKCU restore and the mutex release still run | +| 7 | HKCU restore or verification failure: the backup path and the command are printed | + +### Files written + +Everything goes under `-WorkRoot`: `sandbox\` (the verified copy, reused between runs) and `runs\\` with +`package\` (feed and extracted bridge), `nuget-packages\`, `build\\`, `bundles\\`, +`bundle-manifest..json`, `target-\`, `sessions\\` (registry exports, authorization manifest, +driver events, handshakes, progress) and `receipts\\`, or `smoke-report.json`. The registry exports and the +authorization manifest contain private data and must never be committed. + +## Promise + +- The guard runs before any side effect and refuses every CI marker + (`LocalQualificationRunnerTests.Runner_refuses_to_run_under_CI_before_any_side_effect`, + `Runner_guard_is_the_first_statement_and_the_script_runs_in_strict_mode`). +- No command, file API or robocopy destination of the runner targets the installation + (`Runner_never_writes_to_the_Cheat_Engine_source_directory`), and no workflow references the runner + (`No_workflow_references_the_local_qualification_runner`). +- A receipt assembled from a recorded event log is valid against the v0 schema and the receipt rules + (`Receipt_builder_produces_a_schema_valid_receipt_from_a_recorded_event_log`); redaction removes user paths and keeps + scenario values (`Redaction_removes_user_paths_and_keeps_scenario_values`); registry differences carry names only + (`Registry_diff_reports_value_names_only`); an incomplete bundle is refused + (`Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry`). +- Every Checkpoint B scenario names a matrix cell and only Lua functions its harnesses declare + (`Every_Checkpoint_B_scenario_exists_and_cites_harness_commands_that_exist`), and every generated driver compiles + with Cheat Engine's Lua 5.3 module (`Driver_templates_are_valid_Lua`). +- PSScriptAnalyzer reports no warning or error for this folder. + +## Run the tests + +```powershell +dotnet test --project tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj --filter-class "*LocalQualificationRunnerTests" +Invoke-ScriptAnalyzer -Path eng/qualification -Recurse -Severity Warning,Error +``` + +The tests need PowerShell 7 (`pwsh`) on `PATH`; they fail rather than skip without it. From 0a9f72c8a9a0c58936318b1d8342732d67cb4e65 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 02:31:05 +0200 Subject: [PATCH 031/199] Record the lock-file NuGet content hash in qualification receipts The runner read package.contentHashSha512 from the isolated restore's .nupkg.sha512 file, which is the SHA-512 of the file bytes. Measured on this machine against the global packages folder and a consumer lock file: lock files hold the contentHash of .nupkg.metadata, and for a signed package (every nuget.org download) the two values differ. A NuGetOrg receipt would therefore have carried an identity no lock file can match. For an unsigned CI artifact both values are equal. Get-RestoredPackageContentHash (module, tested) now prefers .nupkg.metadata and falls back to the .sha512 file only when no metadata exists. The runner stops with exit code 6 when the restore recorded no content hash at all, instead of writing a receipt the schema refuses. The protocol and runner README say which value is recorded. --- docs/qualification/local-protocol.md | 4 +- .../Invoke-LocalQualification.ps1 | 4 +- eng/qualification/QualificationRunner.psm1 | 30 +++++++++++++++ eng/qualification/README.md | 9 +++-- .../LocalQualificationRunnerTests.cs | 38 +++++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/docs/qualification/local-protocol.md b/docs/qualification/local-protocol.md index 64c93138..8b758cff 100644 --- a/docs/qualification/local-protocol.md +++ b/docs/qualification/local-protocol.md @@ -46,8 +46,8 @@ Nothing in this protocol runs in CI, and a C1/C2 result is never a substitute fo ``` 3. The runner reads the id and version from the package's `.nuspec`, never from the file name, and records three - identities in each receipt: the SHA-256 of the `.nupkg`, the NuGet content hash (SHA-512, base64) computed by an - isolated restore, and the CI run URL. It builds every plugin from that package only, with an isolated NuGet packages + identities in each receipt: the SHA-256 of the `.nupkg`, the NuGet content hash (SHA-512, base64) that its + isolated restore recorded, which is the value consumer lock files hold, and the CI run URL. It builds every plugin from that package only, with an isolated NuGet packages folder, so a package already in your global NuGet cache can never be used instead. ## Running diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 index 9e651939..ffc9f190 100644 --- a/eng/qualification/Invoke-LocalQualification.ps1 +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -391,13 +391,12 @@ $($compile -join "`n") $problems = @(Test-QualificationBundleClosure -BundleDirectory $bundleDirectory -PluginFileName "$($definition.Assembly).dll" -PackagedBridgeSha256 $Package.bridgeSha256) if ($problems.Count -gt 0) { throw "Bundle $Name is not closed over the package: $($problems -join ' ')" } - $contentHashFile = Join-Path $packagesDirectory ("$($Package.id)/$($Package.version)/$($Package.id).$($Package.version).nupkg.sha512".ToLowerInvariant()) return [ordered]@{ name = $Name directory = $bundleDirectory plugin = Join-Path $bundleDirectory "$($definition.Assembly).dll" warnings = @($buildOutput | Where-Object { $_ -match ': warning ' } | Select-Object -Unique) - contentHash = if (Test-Path -LiteralPath $contentHashFile) { (Get-Content -LiteralPath $contentHashFile -Raw).Trim() } else { $null } + contentHash = Get-RestoredPackageContentHash -PackagesDirectory $packagesDirectory -Id $Package.id -Version $Package.version } } @@ -751,6 +750,7 @@ try { if ($needed -contains 'CoexistenceShared') { $needed = @($needed | Where-Object { $_ -ne 'CoexistenceShared' }) + @('CoexistenceA', 'CoexistenceB') | Select-Object -Unique } foreach ($name in $needed) { $bundles[$name] = Build-Harness -Name $name -RepositoryRoot $RepositoryRoot -RunDirectory $runDirectory -Package $package -FeedDirectory $feed } $package.contentHashSha512 = @($bundles.Values | ForEach-Object contentHash | Where-Object { $_ }) | Select-Object -First 1 + if (-not $package.contentHashSha512) { Exit-Qualification -Code 6 -Reason "The isolated restore recorded no NuGet content hash for $($package.id) $($package.version)." } $driverBundles = [ordered]@{} foreach ($name in $bundles.Keys) { $driverBundles[$name] = $bundles[$name].plugin.Replace('\', '/') } if ($selected | Where-Object { @($_.harnesses) -contains 'CoexistenceShared' }) { diff --git a/eng/qualification/QualificationRunner.psm1 b/eng/qualification/QualificationRunner.psm1 index f3d498ae..e02a4913 100644 --- a/eng/qualification/QualificationRunner.psm1 +++ b/eng/qualification/QualificationRunner.psm1 @@ -526,6 +526,35 @@ function Test-QualificationBundleClosure { return $problems.ToArray() } +function Get-RestoredPackageContentHash { + <# + .SYNOPSIS + The NuGet content hash (SHA-512, base64) an isolated restore recorded for a package, as lock files hold it. + .DESCRIPTION + Reads contentHash from the restore's .nupkg.metadata. The .nupkg.sha512 file beside it is the SHA-512 of the + file bytes, which differs from the lock-file value for a signed package (a nuget.org download); it is used + only when no metadata file exists. Returns $null when the packages folder does not hold the package. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $PackagesDirectory, + [Parameter(Mandatory)] [string] $Id, + [Parameter(Mandatory)] [string] $Version + ) + + $versionDirectory = Join-Path $PackagesDirectory "$($Id.ToLowerInvariant())/$($Version.ToLowerInvariant())" + $metadata = Join-Path $versionDirectory '.nupkg.metadata' + if (Test-Path -LiteralPath $metadata -PathType Leaf) { + $contentHash = Get-OptionalProperty -InputObject (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) -Name 'contentHash' + if ($contentHash) { return [string] $contentHash } + } + + $bytesHash = Join-Path $versionDirectory "$($Id.ToLowerInvariant()).$($Version.ToLowerInvariant()).nupkg.sha512" + if (Test-Path -LiteralPath $bytesHash -PathType Leaf) { return (Get-Content -LiteralPath $bytesHash -Raw).Trim() } + return $null +} + function Test-QualificationWorkRoot { <# .SYNOPSIS @@ -881,6 +910,7 @@ Export-ModuleMember -Function @( 'Get-BundleFileManifest' 'Test-EntryPointExport' 'Test-QualificationBundleClosure' + 'Get-RestoredPackageContentHash' 'Test-QualificationWorkRoot' 'Get-CiEnvironmentVariable' 'Test-HasProperty' diff --git a/eng/qualification/README.md b/eng/qualification/README.md index add7f72b..cb6bb27f 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -16,7 +16,7 @@ operator's Cheat Engine settings changed. | File | Content | |-----------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| | `Invoke-LocalQualification.ps1` | The runner: guard, preflight, mutex, sandbox, bundles, targets, registry, Cheat Engine session, receipts. | -| `QualificationRunner.psm1` | Pure helpers the runner and the tests share: hashing with the LF rule, registry parsing and name-only diff, redaction, event log bounding, receipt id and assembly, Lua literals, bundle closure, pass-rule evaluation. | +| `QualificationRunner.psm1` | Pure helpers the runner and the tests share: hashing with the LF rule, registry parsing and name-only diff, redaction, event log bounding, receipt id and assembly, Lua literals, bundle closure, restored content hash, pass-rule evaluation. | | `driver/zz_cesdk_qualification.template.lua` | The autorun Lua driver template: runs one scenario step per timer tick under `pcall`, appends one JSON event per line, resumes after a Lua state reset. | | `scenarios.json` | The Checkpoint B plan: harnesses, target, steps (Lua or Operator), observed values and a declarative pass rule per scenario. | @@ -39,7 +39,9 @@ operator's Cheat Engine settings changed. paths, `.runtimeconfig.json`, and the bridge equal to the package's `build/native` copy. Q09.a merges A and B into one folder and refuses a same-named file with different bytes. Each bundle gets `bundle-manifest..json` (every file with its SHA-256, and the build warnings). -6. **Bridge identity.** SHA-256 of the packaged bridge and its exported source fingerprint. +6. **Package and bridge identity.** SHA-256 of the `.nupkg`; the NuGet content hash from the isolated restore's + `.nupkg.metadata` (the lock-file value, which differs from a SHA-512 of the file bytes for a signed package); SHA-256 + of the packaged bridge and its exported source fingerprint. 7. **Targets.** Publishes `tests/CheatEngine.SDK.QualificationTarget` for the needed architectures, starts it and reads its ready record; writes the LiveProbe authorization manifest and, for fault scenarios, `liveprobe.fault.json`. 8. **HKCU before.** `reg export` of `HKCU\Software\Cheat Engine` into the session folder. @@ -110,7 +112,8 @@ authorization manifest contain private data and must never be committed. (`Receipt_builder_produces_a_schema_valid_receipt_from_a_recorded_event_log`); redaction removes user paths and keeps scenario values (`Redaction_removes_user_paths_and_keeps_scenario_values`); registry differences carry names only (`Registry_diff_reports_value_names_only`); an incomplete bundle is refused - (`Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry`). + (`Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry`); the recorded content hash is the + lock-file value (`Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash`). - Every Checkpoint B scenario names a matrix cell and only Lua functions its harnesses declare (`Every_Checkpoint_B_scenario_exists_and_cites_harness_commands_that_exist`), and every generated driver compiles with Cheat Engine's Lua 5.3 module (`Driver_templates_are_valid_Lua`). diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs index 380f77dc..661ebdc2 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -291,6 +291,36 @@ public void Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project } } + [Fact] + public void Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash() + { + // For a signed package the restore's .nupkg.metadata contentHash (what lock files hold) differs from the + // .nupkg.sha512 file (SHA-512 of the file bytes); the receipt records the former. + string lockFileHash = new string('A', 86) + "=="; + string bytesHash = new string('Q', 86) + "=="; + string packages = Path.Combine(Path.GetTempPath(), "cesdk-packages-" + Guid.NewGuid().ToString("N")); + string version = Path.Combine(packages, "cheatengine.sdk", "2.0.0-alpha.0.12"); + Directory.CreateDirectory(version); + try + { + File.WriteAllText(Path.Combine(version, "cheatengine.sdk.2.0.0-alpha.0.12.nupkg.sha512"), bytesHash); + string bytesOnly = ContentHash(packages); + File.WriteAllText(Path.Combine(version, ".nupkg.metadata"), + $$"""{ "version": 2, "contentHash": "{{lockFileHash}}", "source": "qualified-package" }"""); + string withMetadata = ContentHash(packages); + Directory.Delete(version, true); + string absent = ContentHash(packages); + + Assert.Equal(bytesHash, bytesOnly); + Assert.Equal(lockFileHash, withMetadata); + Assert.Equal("absent", absent); + } + finally + { + Directory.Delete(packages, true); + } + } + [Fact] public void Every_Checkpoint_B_scenario_exists_and_cites_harness_commands_that_exist() { @@ -454,6 +484,14 @@ private static JsonElement Closure(string bundle, string packagedBridge) $"ConvertTo-Json -Compress -InputObject @(Test-QualificationBundleClosure -BundleDirectory {PowerShellProcess.Quote(bundle)} -PluginFileName 'Plugin.dll' -PackagedBridgeSha256 '{packagedBridge}')"); } + private static string ContentHash(string packages) + { + JsonElement result = RunJson(ModuleImport + + $"$hash = Get-RestoredPackageContentHash -PackagesDirectory {PowerShellProcess.Quote(packages)} -Id 'CheatEngine.SDK' -Version '2.0.0-alpha.0.12'; " + + "ConvertTo-Json -Compress -InputObject $(if ($null -eq $hash) { 'absent' } else { $hash })"); + return result.GetString()!; + } + private static JsonElement RunJson(string script) { PowerShellProcess.Result result = PowerShellProcess.RunCommand(script, null, TestContext.Current.CancellationToken); From 1b0273896e09de532e58b1a0d002f9228beccdfe Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 02:32:20 +0200 Subject: [PATCH 032/199] Count only Cheat Engine executables as another instance The runner treated every process whose name starts with "cheatengine" (case-insensitive) as another Cheat Engine instance. On a machine that runs the SDK or Client test suites, a test host such as CheatEngine.Client.Repository.Tests matched: the preflight refused the run, and after a session a due HKCU restore became a false exit code 7. Test-CheatEngineProcessName (module, tested) accepts only Cheat Engine's own executables: cheatengine-x86_64, its SSE4-AVX2 variant, cheatengine-i386 and the "Cheat Engine" launcher. --- .../Invoke-LocalQualification.ps1 | 2 +- eng/qualification/QualificationRunner.psm1 | 15 +++++++++++++++ eng/qualification/README.md | 4 +++- .../LocalQualificationRunnerTests.cs | 19 +++++++++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 index ffc9f190..9d9e4ccf 100644 --- a/eng/qualification/Invoke-LocalQualification.ps1 +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -159,7 +159,7 @@ function Get-CheatEngineProcess { [OutputType([System.Diagnostics.Process[]])] param() - return @(Get-Process | Where-Object { $_.ProcessName -match '^(cheatengine|Cheat Engine)' }) + return @(Get-Process | Where-Object { Test-CheatEngineProcessName -Name $_.ProcessName }) } function Invoke-Preflight { diff --git a/eng/qualification/QualificationRunner.psm1 b/eng/qualification/QualificationRunner.psm1 index e02a4913..950fc989 100644 --- a/eng/qualification/QualificationRunner.psm1 +++ b/eng/qualification/QualificationRunner.psm1 @@ -526,6 +526,20 @@ function Test-QualificationBundleClosure { return $problems.ToArray() } +function Test-CheatEngineProcessName { + <# + .SYNOPSIS + True for the process names of Cheat Engine itself: the x64, x64 SSE4-AVX2 and i386 executables and the + launcher. Other processes whose name merely starts with "CheatEngine" (the SDK and Client test hosts, the + qualification target) are not Cheat Engine and never block a run or an HKCU restore. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name) + + return $Name -match '^(?:cheatengine-(?:x86_64|i386)(?:-SSE4-AVX2)?|Cheat Engine)$' +} + function Get-RestoredPackageContentHash { <# .SYNOPSIS @@ -910,6 +924,7 @@ Export-ModuleMember -Function @( 'Get-BundleFileManifest' 'Test-EntryPointExport' 'Test-QualificationBundleClosure' + 'Test-CheatEngineProcessName' 'Get-RestoredPackageContentHash' 'Test-QualificationWorkRoot' 'Get-CiEnvironmentVariable' diff --git a/eng/qualification/README.md b/eng/qualification/README.md index cb6bb27f..912ba62f 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -113,7 +113,9 @@ authorization manifest contain private data and must never be committed. scenario values (`Redaction_removes_user_paths_and_keeps_scenario_values`); registry differences carry names only (`Registry_diff_reports_value_names_only`); an incomplete bundle is refused (`Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry`); the recorded content hash is the - lock-file value (`Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash`). + lock-file value (`Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash`); only Cheat + Engine's own executables count as another instance, never a process such as a `CheatEngine.*` test host + (`Only_Cheat_Engine_executables_count_as_another_instance`). - Every Checkpoint B scenario names a matrix cell and only Lua functions its harnesses declare (`Every_Checkpoint_B_scenario_exists_and_cites_harness_commands_that_exist`), and every generated driver compiles with Cheat Engine's Lua 5.3 module (`Driver_templates_are_valid_Lua`). diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs index 661ebdc2..12e8d07b 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -291,6 +291,25 @@ public void Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project } } + [Fact] + public void Only_Cheat_Engine_executables_count_as_another_instance() + { + // A false positive refuses the preflight and, after a session, turns a due HKCU restore into exit code 7. + JsonElement result = RunJson(ModuleImport + """ + $names = 'cheatengine-x86_64', 'cheatengine-x86_64-SSE4-AVX2', 'cheatengine-i386', 'Cheat Engine', 'CHEATENGINE-X86_64', + 'CheatEngine.Client.Repository.Tests', 'CheatEngine.SDK.Tests', 'CheatEngine.SDK.QualificationTarget', 'cheatengine-x86_64-old', 'Tutorial-x86_64', 'gtutorial-i386' + $result = [ordered]@{} + foreach ($name in $names) { $result[$name] = Test-CheatEngineProcessName -Name $name } + $result | ConvertTo-Json -Compress + """); + + string[] cheatEngine = ["cheatengine-x86_64", "cheatengine-x86_64-SSE4-AVX2", "cheatengine-i386", "Cheat Engine", "CHEATENGINE-X86_64"]; + foreach (JsonProperty name in result.EnumerateObject()) + { + Assert.True(name.Value.GetBoolean() == cheatEngine.Contains(name.Name, StringComparer.Ordinal), name.Name); + } + } + [Fact] public void Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash() { From 08030bd175dbd67ec7bf289409ba22abc693edc8 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 02:40:47 +0200 Subject: [PATCH 033/199] Group the qualification solution folders with the docs folders After the rebase onto the S-DOC documentation index, /docs/qualification/ and /docs/qualification/schemas/ sat below /native/cheat-engine/, apart from the /docs/, /docs/abi/ and /docs/catalog/ folders S-DOC added. Move the two S-QUAL folder blocks right after /docs/catalog/. No entry of another folder moves or changes. --- CheatEngine.SDK.slnx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index cfbd0f27..9bdfc91e 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -56,10 +56,6 @@ - - - - @@ -73,6 +69,10 @@ + + + + From 6bcf689b65a247bc55e845fcc17f67918202a3e2 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 02:47:49 +0200 Subject: [PATCH 034/199] Accept a comma-separated -Scenario list under pwsh -File The runner's help, README and protocol show "-Scenario Q04,Q14". From a PowerShell prompt that is an array, but "pwsh -File ... -Scenario Q04,Q14" passes one string, which the runner refused as the unknown scenario 'Q04,Q14' (exit 2, before any side effect). Split every value on commas and trim it, so both invocation styles select the same scenarios; an unknown id is still refused by name. --- eng/qualification/Invoke-LocalQualification.ps1 | 3 ++- eng/qualification/README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 index 9d9e4ccf..3b09cfde 100644 --- a/eng/qualification/Invoke-LocalQualification.ps1 +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -685,7 +685,8 @@ $workRootProblem = Test-QualificationWorkRoot -WorkRoot $WorkRoot -RepositoryRoo if ($workRootProblem) { Exit-Qualification -Code 5 -Reason $workRootProblem } $selected = [System.Collections.Generic.List[object]]::new() -foreach ($id in $Scenario) { +# pwsh -File passes "-Scenario Q04,Q14" as one string; split it so both invocation styles select the same scenarios. +foreach ($id in @($Scenario | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { if ($id -eq 'CheckpointB') { foreach ($definition in $plan.scenarios) { if ($definition.support -notin 'Manual', 'NotApplicable') { $selected.Add($definition) } } continue diff --git a/eng/qualification/README.md b/eng/qualification/README.md index 912ba62f..f3c7166a 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -63,7 +63,7 @@ operator's Cheat Engine settings changed. | Parameter | Meaning | |------------------------------------|-----------------------------------------------------------------------------------------------------------| -| `-Scenario ` | Scenario ids of `scenarios.json` (`Q04`, `Q09.a`, …) or `CheckpointB` (default) for every runnable one. | +| `-Scenario ` | Scenario ids of `scenarios.json` (`Q04`, `Q09.a`, …) or `CheckpointB` (default) for every runnable one; a comma-separated string (`pwsh -File … -Scenario Q04,Q14`) is split. | | `-PackagePath ` | The exact `CheatEngine.SDK` `.nupkg`. Required unless `-PreflightOnly`. | | `-PackageSource CiArtifact\|NuGetOrg` | Where the package came from (default `CiArtifact`). | | `-CiRunUrl ` | The CI run that produced the artifact; required for `CiArtifact` receipts. | From 6ed6a06faa6b11da58b99c8c76170904ec189e9f Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:24:35 +0200 Subject: [PATCH 035/199] Apply class-level Qualification traits to every test of the class S-SUPPLY put [Trait("Qualification", "Q48")] on the CompatibilitySuppressionTests class (SDK-side C0 evidence for Q48). The trait index accepted method-level traits only, so the trait-parity test failed on the merged tree and would turn the CI build-test legs red. xUnit applies a class-level trait to every test method of the class. The index now does the same: a trait on a top-level class yields one entry per [Fact]/[Theory] method, the attribute check of Automated evidence includes the class traits of a test method, and a trait on a nested type or on a class without test methods stays a violation. A synthetic-source test pins these rules. The matrix gains the Q48 C0 cell (Passed, Functional, ObservedSource) citing the six CompatibilitySuppressionTests methods, with a justification that it never closes Q48 at C1/C3 or F05; the README matrix summary and trait paragraph follow. --- docs/qualification/README.md | 4 +- docs/qualification/matrix.json | 51 ++++++ .../Qualification/QualificationMatrixTests.cs | 77 ++++++++ .../Validation/TestSourceIndex.cs | 165 +++++++++++++++--- 4 files changed, 275 insertions(+), 22 deletions(-) diff --git a/docs/qualification/README.md b/docs/qualification/README.md index 93b482d2..c65bf4bb 100644 --- a/docs/qualification/README.md +++ b/docs/qualification/README.md @@ -52,6 +52,8 @@ adds the test that evidences it. **Traits.** A test that evidences a scenario carries `[Trait("Qualification", "Qxx")]` on the method, and the matrix cites it; every trait in the test sources is cited and every citation resolves to a traited method of a CI test module. +A trait on a top-level test class applies, as in xUnit, to every `[Fact]`/`[Theory]` method of that class, and the +matrix then cites each of those methods. `dotnet test --project --filter-trait "Qualification=Q07"` runs exactly the evidence of one row. The mapping of the existing tests was checked against the audit's success criterion of each row; tests whose assertions do not prove the criterion stay untagged. @@ -125,7 +127,7 @@ Generated from [`matrix.json`](matrix.json) and checked by | Q45 | Sensitive availability probe | Client | C1, C3 | – | Not applicable | – | Not applicable | – | | Q46 | Logs containing user data or expressions | Both | C1, C3 | – | Not executed | – | Not executed | – | | Q47 | Inherited property or method and public alias | SDK | C2, C3 | – | – | Not executed | Not executed | – | -| Q48 | SDK package updated without adapting the Client | Both | C1, C3 | – | Not executed | – | Not executed | – | +| Q48 | SDK package updated without adapting the Client | Both | C1, C3 | Passed | Not executed | – | Not executed | – | The rows still missing evidence are listed in the [support profile](support-profile.md#not-executed). diff --git a/docs/qualification/matrix.json b/docs/qualification/matrix.json index 21d04b3e..ffb77ed2 100644 --- a/docs/qualification/matrix.json +++ b/docs/qualification/matrix.json @@ -2641,6 +2641,57 @@ "expectedCategory": "Refused" }, "levels": { + "C0": { + "status": "Passed", + "passKind": "Functional", + "evidenceKind": "ObservedSource", + "evidence": [ + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj", + "file": "tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs", + "test": "CompatibilitySuppressionTests.Every_suppression_is_a_baseline_suppression_of_one_library_against_itself", + "trait": "Qualification=Q48" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj", + "file": "tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs", + "test": "CompatibilitySuppressionTests.Every_baseline_suppression_matches_a_removed_public_api_line", + "trait": "Qualification=Q48" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj", + "file": "tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs", + "test": "CompatibilitySuppressionTests.Every_removed_public_api_line_is_suppressed_or_declared_invisible_to_apicompat", + "trait": "Qualification=Q48" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj", + "file": "tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs", + "test": "CompatibilitySuppressionTests.Every_invisible_change_names_a_current_removed_line_with_a_reason", + "trait": "Qualification=Q48" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj", + "file": "tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs", + "test": "CompatibilitySuppressionTests.Suppressions_touching_client_consumed_types_are_listed_as_induced_client_breaks", + "trait": "Qualification=Q48" + }, + { + "kind": "Automated", + "project": "tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj", + "file": "tests/CheatEngine.SDK.Repository.Tests/PublicApi/CompatibilitySuppressionTests.cs", + "test": "CompatibilitySuppressionTests.Every_client_consumed_type_resolves_in_the_declared_api_or_is_marked_unresolved", + "trait": "Qualification=Q48" + } + ], + "justification": "SDK-side static contract only: the ApiCompat baseline suppressions, the removed PublicAPI lines and the reviewed invisible changes against CheatEngine.SDK 1.0.0 agree, and breaks on Client-consumed types are listed. It never closes Q48 at C1 or C3 (Client consumer contracts, advisory client-canary) and never closes F05.", + "date": "2026-09-23" + }, "C1": { "status": "NotExecuted", "evidenceKind": "ToQualify" diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationMatrixTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationMatrixTests.cs index 4e95b30e..bd961368 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationMatrixTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/QualificationMatrixTests.cs @@ -21,6 +21,62 @@ public sealed class QualificationMatrixTests "Q32.c", "Q32.d" ]; + // A class-level trait sample. QUALIFICATION stands for the quoted trait name, so this file holds no literal trait + // the real scan would index. + private const string ClassTraitSample = """ + namespace Sample; + + /// A class-level trait, as xUnit applies it. + [Trait(QUALIFICATION, "Q48")] + public sealed class ClassTraited + { + [Fact] + public void First() + { + } + + [Theory] + [InlineData(1)] + public void Second(int value) + { + } + + [Fact] + [Trait(QUALIFICATION, "Q01")] + public void Third() + { + } + + private static void Helper() + { + } + } + + [Trait(QUALIFICATION, "Q47")] + public sealed class NoTests + { + public void NotATest() + { + } + } + + public sealed class Outer + { + [Trait(QUALIFICATION, "Q46")] + public sealed class Nested + { + [Fact] + public void Inner() + { + } + } + } + """; + + private static string[] ClassTraitSampleLines => ClassTraitSample + .Replace("QUALIFICATION", "\"" + TestSourceIndex.TraitName + "\"", StringComparison.Ordinal) + .ReplaceLineEndings("\n").Split('\n'); + private static JsonElement MatrixJson => QualificationDocuments.LoadJson(QualificationDocuments.MatrixPath); private static QualificationMatrix Matrix => QualificationMatrix.Read(MatrixJson); @@ -241,6 +297,27 @@ public void Every_Qualification_trait_in_the_tests_appears_in_the_matrix_and_vic StringComparison.Ordinal) && string.Equals(trait.Value, "Q07", StringComparison.Ordinal)); } + [Fact] + public void Class_level_Qualification_traits_apply_to_every_test_method_of_the_class() + { + string[] lines = ClassTraitSampleLines; + + TestSourceIndex index = TestSourceIndex.ScanSource("tests/Sample.Tests/ClassTraited.cs", lines); + + Assert.Equal(["ClassTraited.First Q48", "ClassTraited.Second Q48", "ClassTraited.Third Q01", "ClassTraited.Third Q48"], + index.Traits.Select(static trait => trait.Test + " " + trait.Value).Order(StringComparer.Ordinal), + StringComparer.Ordinal); + Assert.Equal(2, index.Violations.Count); + Assert.Contains(index.Violations, static violation => + violation.Contains("'Q47' is on class NoTests, which declares no [Fact] or [Theory]", StringComparison.Ordinal)); + Assert.Contains(index.Violations, static violation => + violation.Contains("'Q46' is on a nested type", StringComparison.Ordinal)); + Assert.Equal(["Q48"], TestSourceIndex.TraitsOfMethod(lines, "ClassTraited", "Second")!); + Assert.Equal(["Q01", "Q48"], TestSourceIndex.TraitsOfMethod(lines, "ClassTraited", "Third")!); + Assert.Empty(TestSourceIndex.TraitsOfMethod(lines, "ClassTraited", "Helper")!); + Assert.Null(TestSourceIndex.TraitsOfMethod(lines, "ClassTraited", "Missing")); + } + [Fact] public void Parent_rows_aggregate_their_sub_rows() { diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TestSourceIndex.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TestSourceIndex.cs index 005fdc2a..431764ca 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TestSourceIndex.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/Validation/TestSourceIndex.cs @@ -5,9 +5,10 @@ namespace CheatEngine.SDK.Repository.Tests.Qualification.Validation; /// -/// A text index of the Qualification traits in the test sources. A trait counts as evidence only on a test -/// method: it sits in the attribute block directly above the method signature. A class-level trait is reported as a -/// violation, because it would qualify every method of the class at once. +/// A text index of the Qualification traits in the test sources. A trait counts as evidence only on a test: +/// in the attribute block directly above a method signature, or in the attribute block of a top-level test class, +/// where it applies, as in xUnit, to every [Fact]/[Theory] method the class declares. A trait anywhere +/// else (a nested type, a class without test methods, no declaration below it) is reported as a violation. /// internal sealed class TestSourceIndex { @@ -28,19 +29,24 @@ internal sealed class TestSourceIndex "^\\s*(?:(?:public|internal|private|protected|static|async|unsafe|override|virtual|sealed|new)\\s+)+[A-Za-z_][A-Za-z0-9_<>,.?\\[\\] ]*\\s+(?[A-Za-z_][A-Za-z0-9_]*)\\s*\\(", RegexOptions.CultureInvariant, RegexTimeout); + // [Fact], [Theory], [Fact(...)], [Xunit.Theory], [SomeFact] and attribute lists such as [Fact, Trait(...)]. + private static readonly Regex TestAttribute = new( + "[\\[,]\\s*(?:[A-Za-z_][A-Za-z0-9_]*\\.)*[A-Za-z0-9_]*(?:Fact|Theory)(?:Attribute)?\\s*[\\](,]", + RegexOptions.CultureInvariant, RegexTimeout); + private TestSourceIndex(IReadOnlyList traits, IReadOnlyList violations) { Traits = traits; Violations = violations; } - /// Every method-level Qualification trait found. + /// Every Qualification trait that applies to a test method, one entry per method and value. internal IReadOnlyList Traits { get; } - /// Traits that are not on a method (class-level, or not followed by a declaration). + /// Traits that apply to no test method (nested type, class without tests, no declaration). internal IReadOnlyList Violations { get; @@ -65,29 +71,47 @@ internal static TestSourceIndex Scan() return new TestSourceIndex(traits, violations); } + /// Indexes one source given as lines, reported under . + internal static TestSourceIndex ScanSource(string file, string[] lines) + { + List traits = []; + List violations = []; + ScanFile(file, lines, traits, violations); + return new TestSourceIndex(traits, violations); + } + /// - /// Returns the Qualification traits in the attribute block of declared in type + /// Returns the Qualification traits that apply to declared in type /// of , or when the method is absent. /// internal static IReadOnlyList? TraitsOfMethod(string file, string className, string methodName) { string path = QualificationDocuments.Absolute(file); - if (!File.Exists(path)) - { - return null; - } + return File.Exists(path) ? TraitsOfMethod(File.ReadAllLines(path), className, methodName) : null; + } - string[] lines = File.ReadAllLines(path); - string? currentType = null; - for (int index = 0; index < lines.Length; index++) + /// + /// Returns the Qualification traits in the attribute block of the method and, when it is a [Fact] or + /// [Theory], those of its top-level class; when the method is absent. + /// + internal static IReadOnlyList? TraitsOfMethod(string[] lines, string className, string methodName) + { + foreach (MethodSite method in Methods(lines)) { - currentType = TopLevelTypeName(lines[index]) ?? currentType; - Match method = MethodDeclaration.Match(lines[index]); - if (method.Success && string.Equals(method.Groups["name"].Value, methodName, StringComparison.Ordinal) && - string.Equals(currentType, className, StringComparison.Ordinal)) + if (!string.Equals(method.Name, methodName, StringComparison.Ordinal) || + !string.Equals(method.TypeName, className, StringComparison.Ordinal)) { - return TraitValues(AttributeBlockAbove(lines, index)); + continue; + } + + string block = AttributeBlockAbove(lines, method.Index); + List values = TraitValues(block); + if (IsTestMethod(block)) + { + values.AddRange(ClassTraits(lines, className)); } + + return values; } return null; @@ -96,6 +120,7 @@ internal static TestSourceIndex Scan() private static void ScanFile(string file, string[] lines, List traits, List violations) { string? currentType = null; + List classTraits = []; for (int index = 0; index < lines.Length; index++) { currentType = TopLevelTypeName(lines[index]) ?? currentType; @@ -110,11 +135,27 @@ private static void ScanFile(string file, string[] lines, List traits, continue; } + if (TypeDeclaration.IsMatch(lines[declaration])) + { + string? typeName = TopLevelTypeName(lines[declaration]); + if (typeName is null) + { + violations.Add(location + " Qualification trait '" + value + + "' is on a nested type; class-level traits apply to top-level test classes only."); + } + else + { + classTraits.Add(new ClassTrait(typeName, value, index + 1, location)); + } + + continue; + } + Match method = MethodDeclaration.Match(lines[declaration]); - if (!method.Success || TypeDeclaration.IsMatch(lines[declaration])) + if (!method.Success) { violations.Add(location + " Qualification trait '" + value + - "' is not on a test method (method-level traits only)."); + "' is not on a test method or a top-level test class."); continue; } @@ -122,6 +163,79 @@ private static void ScanFile(string file, string[] lines, List traits, index + 1)); } } + + if (classTraits.Count > 0) + { + ApplyClassTraits(file, lines, classTraits, traits, violations); + } + } + + // xUnit applies a class-level trait to every test method of the class: one TraitUse per [Fact]/[Theory] method. + private static void ApplyClassTraits(string file, string[] lines, List classTraits, + List traits, List violations) + { + List methods = Methods(lines); + foreach (ClassTrait classTrait in classTraits) + { + int applied = 0; + foreach (MethodSite method in methods) + { + if (string.Equals(method.TypeName, classTrait.TypeName, StringComparison.Ordinal) && + IsTestMethod(AttributeBlockAbove(lines, method.Index))) + { + traits.Add(new TraitUse(file, classTrait.TypeName, method.Name, classTrait.Value, classTrait.Line)); + applied++; + } + } + + if (applied == 0) + { + violations.Add(classTrait.Location + " Qualification trait '" + classTrait.Value + "' is on class " + + classTrait.TypeName + ", which declares no [Fact] or [Theory] method."); + } + } + } + + // Every method declaration with the top-level type that contains it (methods of nested types count for the + // top-level type, as the attribute scan has always done). + private static List Methods(string[] lines) + { + List methods = []; + string? currentType = null; + for (int index = 0; index < lines.Length; index++) + { + currentType = TopLevelTypeName(lines[index]) ?? currentType; + if (currentType is null || TypeDeclaration.IsMatch(lines[index])) + { + continue; + } + + Match method = MethodDeclaration.Match(lines[index]); + if (method.Success) + { + methods.Add(new MethodSite(currentType, method.Groups["name"].Value, index)); + } + } + + return methods; + } + + private static List ClassTraits(string[] lines, string className) + { + for (int index = 0; index < lines.Length; index++) + { + if (string.Equals(TopLevelTypeName(lines[index]), className, StringComparison.Ordinal)) + { + return TraitValues(AttributeBlockAbove(lines, index)); + } + } + + return []; + } + + private static bool IsTestMethod(string attributeBlock) + { + return TestAttribute.IsMatch(attributeBlock); } // Test sources use file-scoped namespaces, so a top-level type declaration starts in column 0; nested types are @@ -196,10 +310,19 @@ private static List TraitValues(string attributeBlock) return values; } - /// One method-level Qualification trait. + /// One Qualification trait applied to one test method. + /// The repository-relative source file. + /// The top-level class that declares the method. + /// The test method. + /// The trait value, a matrix row id. + /// The 1-based line of the trait (on the method or on its class). internal sealed record TraitUse(string File, string ClassName, string MethodName, string Value, int Line) { /// The Class.Method form used by Automated evidence. internal string Test => ClassName + "." + MethodName; } + + private sealed record MethodSite(string TypeName, string Name, int Index); + + private sealed record ClassTrait(string TypeName, string Value, int Line, string Location); } From 40f3d0bfe488ff634014aa5d26a182b392da46f5 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:24:50 +0200 Subject: [PATCH 036/199] Accept package-built plugin bundles in the runner closure check The closure check of stage 5 rejected every real bundle, so Build-Harness threw and the runner exited 6 before any scenario ran: - it flagged any "type": "project" text in the .deps.json, but every .deps.json lists the plugin itself as a project library (the root entry); - it required CESDK.CESDK to be public, while the package's entry-point generator emits "internal static class CESDK", so the LivePlugin and Coexistence bundles (Q09.a, Q09.b) always failed. The .deps.json is now parsed: only a project library other than the plugin's own / entry is refused, and the runner also requires the package library CheatEngine.SDK/ read from the .nuspec (Q40, ADR-10). The entry point may be a public or internal top-level static class; the public static int CEPluginInitialize(nint, int) check is unchanged. A positive test builds a realistic package-consumer bundle with the generated internal entry point and checks entry-point variants compiled on the fly. --- .../Invoke-LocalQualification.ps1 | 2 +- eng/qualification/QualificationRunner.psm1 | 85 ++++++++++++++--- eng/qualification/README.md | 13 ++- .../LocalQualificationRunnerTests.cs | 95 +++++++++++++++++++ 4 files changed, 176 insertions(+), 19 deletions(-) diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 index 3b09cfde..1bba5ba0 100644 --- a/eng/qualification/Invoke-LocalQualification.ps1 +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -388,7 +388,7 @@ $($compile -join "`n") $env:NUGET_PACKAGES = $previousPackages } - $problems = @(Test-QualificationBundleClosure -BundleDirectory $bundleDirectory -PluginFileName "$($definition.Assembly).dll" -PackagedBridgeSha256 $Package.bridgeSha256) + $problems = @(Test-QualificationBundleClosure -BundleDirectory $bundleDirectory -PluginFileName "$($definition.Assembly).dll" -PackagedBridgeSha256 $Package.bridgeSha256 -PackageLibrary "$($Package.id)/$($Package.version)") if ($problems.Count -gt 0) { throw "Bundle $Name is not closed over the package: $($problems -join ' ')" } return [ordered]@{ diff --git a/eng/qualification/QualificationRunner.psm1 b/eng/qualification/QualificationRunner.psm1 index 950fc989..f9902f7a 100644 --- a/eng/qualification/QualificationRunner.psm1 +++ b/eng/qualification/QualificationRunner.psm1 @@ -418,8 +418,9 @@ function Get-BundleFileManifest { function Test-EntryPointExport { <# .SYNOPSIS - True when the assembly declares the public static class CESDK.CESDK with a public static - int CEPluginInitialize(nint, int), read with System.Reflection.Metadata without loading the assembly. + True when the assembly declares the top-level static class CESDK.CESDK (public, or internal as the package's + entry-point generator emits it) with a public static int CEPluginInitialize(nint, int), read with + System.Reflection.Metadata without loading the assembly. #> [CmdletBinding()] [OutputType([bool])] @@ -435,10 +436,15 @@ function Test-EntryPointExport { $type = $reader.GetTypeDefinition($typeHandle) if ($reader.GetString($type.Namespace) -cne 'CESDK' -or $reader.GetString($type.Name) -cne 'CESDK') { continue } $typeAttributes = $type.Attributes - $isPublicStatic = (($typeAttributes -band [System.Reflection.TypeAttributes]::VisibilityMask) -eq [System.Reflection.TypeAttributes]::Public) -and + # Top-level visibility is Public or NotPublic (internal); a static class is abstract and sealed. Cheat Engine + # resolves the type by name through the runtime host, which does not require it to be public. + $visibility = $typeAttributes -band [System.Reflection.TypeAttributes]::VisibilityMask + $isTopLevel = $visibility -eq [System.Reflection.TypeAttributes]::Public -or $visibility -eq [System.Reflection.TypeAttributes]::NotPublic + $isStaticClass = $isTopLevel -and + (($typeAttributes -band [System.Reflection.TypeAttributes]::Interface) -eq 0) -and (($typeAttributes -band [System.Reflection.TypeAttributes]::Abstract) -ne 0) -and (($typeAttributes -band [System.Reflection.TypeAttributes]::Sealed) -ne 0) - if (-not $isPublicStatic) { return $false } + if (-not $isStaticClass) { return $false } foreach ($methodHandle in $type.GetMethods()) { $method = $reader.GetMethodDefinition($methodHandle) if ($reader.GetString($method.Name) -cne 'CEPluginInitialize') { continue } @@ -466,19 +472,73 @@ function Test-EntryPointExport { } } +function Get-DepsJsonProblem { + <# + .SYNOPSIS + The problems of a plugin's .deps.json: a "project" library other than the plugin's own root entry + (/, which every .deps.json lists), a missing "package" library for the qualified package + when -PackageLibrary names it (for example CheatEngine.SDK/2.0.0-alpha.0.12), or an absolute path. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] [AllowEmptyString()] [string] $Text, + [Parameter(Mandatory)] [string] $PluginName, + [string] $PackageLibrary = '' + ) + + $problems = [System.Collections.Generic.List[string]]::new() + $fileName = "$PluginName.deps.json" + if ($Text -match '(? [CmdletBinding()] [OutputType([string[]])] param( [Parameter(Mandatory)] [string] $BundleDirectory, [Parameter(Mandatory)] [string] $PluginFileName, - [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{64}$')] [string] $PackagedBridgeSha256 + [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{64}$')] [string] $PackagedBridgeSha256, + [string] $PackageLibrary = '' ) $problems = [System.Collections.Generic.List[string]]::new() @@ -487,7 +547,7 @@ function Test-QualificationBundleClosure { $problems.Add("The plugin $PluginFileName is missing.") } elseif (-not (Test-EntryPointExport -AssemblyPath $plugin)) { - $problems.Add("$PluginFileName declares no public static CESDK.CESDK.CEPluginInitialize(nint, int).") + $problems.Add("$PluginFileName declares no static class CESDK.CESDK with a public static int CESDK.CESDK.CEPluginInitialize(nint, int).") } foreach ($assembly in $script:SdkAssemblies) { @@ -502,12 +562,8 @@ function Test-QualificationBundleClosure { $problems.Add("$baseName.deps.json is missing.") } else { - $depsText = [System.IO.File]::ReadAllText($deps) - if ($depsText -match '"type"\s*:\s*"project"') { - $problems.Add("$baseName.deps.json contains a workspace project entry; the SDK must come from the package.") - } - if ($depsText -match '(?` and package source mapping, and an isolated `NUGET_PACKAGES`; restored with - `--no-http-cache --force-evaluate`, published, then checked: plugin with `CESDK.CESDK.CEPluginInitialize(nint, int)` - (read with System.Reflection.Metadata), the six SDK assemblies, `.deps.json` without project entries or absolute - paths, `.runtimeconfig.json`, and the bridge equal to the package's `build/native` copy. Q09.a merges A and B into one + `--no-http-cache --force-evaluate`, published, then checked: plugin with a static `CESDK.CESDK` class, public or + internal as the package's entry-point generator emits it, and a public static `CEPluginInitialize(nint, int)` (read + with System.Reflection.Metadata), the six SDK assemblies, a `.deps.json` whose only `project` library is the plugin's + own root entry, that lists `CheatEngine.SDK/` as a `package` and holds no absolute path, + `.runtimeconfig.json`, and the bridge equal to the package's `build/native` copy. Q09.a merges A and B into one folder and refuses a same-named file with different bytes. Each bundle gets `bundle-manifest..json` (every file with its SHA-256, and the build warnings). 6. **Package and bridge identity.** SHA-256 of the `.nupkg`; the NuGet content hash from the isolated restore's @@ -112,7 +114,10 @@ authorization manifest contain private data and must never be committed. (`Receipt_builder_produces_a_schema_valid_receipt_from_a_recorded_event_log`); redaction removes user paths and keeps scenario values (`Redaction_removes_user_paths_and_keeps_scenario_values`); registry differences carry names only (`Registry_diff_reports_value_names_only`); an incomplete bundle is refused - (`Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry`); the recorded content hash is the + (`Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry`) and a bundle built from the package, + with the generated internal entry point, is accepted + (`Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_generated_internal_entry_point`); the recorded + content hash is the lock-file value (`Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash`); only Cheat Engine's own executables count as another instance, never a process such as a `CheatEngine.*` test host (`Only_Cheat_Engine_executables_count_as_another_instance`). diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs index 12e8d07b..c8988897 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -25,6 +25,23 @@ public sealed class LocalQualificationRunnerTests "Q39", "Q40" ]; + private const string PackageConsumerDeps = """ + { + "runtimeTarget": { "name": ".NETCoreApp,Version=v10.0", "signature": "" }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": { + "Plugin/1.0.0": { "dependencies": { "CheatEngine.SDK": "2.0.0-alpha.0.12" }, "runtime": { "Plugin.dll": {} } }, + "CheatEngine.SDK/2.0.0-alpha.0.12": { "runtime": { "lib/net10.0/CheatEngine.SDK.Hosting.dll": { "assemblyVersion": "2.0.0.0", "fileVersion": "2.0.0.0" } } } + } + }, + "libraries": { + "Plugin/1.0.0": { "type": "project", "serviceable": false, "sha512": "" }, + "CheatEngine.SDK/2.0.0-alpha.0.12": { "type": "package", "serviceable": true, "sha512": "sha512-AAAA", "path": "cheatengine.sdk/2.0.0-alpha.0.12", "hashPath": "cheatengine.sdk.2.0.0-alpha.0.12.nupkg.sha512" } + } + } + """; + private static string ModuleImport => "Import-Module " + PowerShellProcess.Quote(QualificationDocuments.Absolute(RunnerModule)) + " -Force; "; @@ -291,6 +308,39 @@ public void Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project } } + [Fact] + public void Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_generated_internal_entry_point() + { + string root = Path.Combine(Path.GetTempPath(), "cesdk-bundle-" + Guid.NewGuid().ToString("N")); + string bundle = Path.Combine(root, "bundle"); + string variants = Path.Combine(root, "variants"); + Directory.CreateDirectory(variants); + try + { + string bridgeSha256 = WritePackageConsumerBundle(bundle); + + JsonElement result = RunJson(EntryPointAndClosureScript(bundle, variants, bridgeSha256)); + + Assert.True(result.GetProperty("accepted").GetArrayLength() == 0, result.GetProperty("accepted").GetRawText()); + string otherPackage = Assert.Single(result.GetProperty("otherPackage").EnumerateArray()).GetString()!; + Assert.Contains("no package library 'CheatEngine.SDK/2.0.0-alpha.0.13'", otherPackage, StringComparison.Ordinal); + Dictionary entryPoints = result.GetProperty("entryPoints").EnumerateObject() + .ToDictionary(static property => property.Name, static property => property.Value.GetBoolean(), StringComparer.Ordinal); + Assert.Equal(new Dictionary(StringComparer.Ordinal) + { + ["internalStatic"] = true, + ["publicStatic"] = true, + ["notStatic"] = false, + ["privateMethod"] = false, + ["wrongSignature"] = false, + ["otherNamespace"] = false + }, entryPoints); + } + finally + { + Directory.Delete(root, true); + } + } [Fact] public void Only_Cheat_Engine_executables_count_as_another_instance() { @@ -497,6 +547,51 @@ private static HashSet HarnessLuaFunctions() return names; } + // What a harness built from the exact package looks like: the plugin's own root "project" entry and a "package" entry + // for CheatEngine.SDK. Returns the SHA-256 of the bundle's bridge. + private static string WritePackageConsumerBundle(string bundle) + { + Directory.CreateDirectory(bundle); + foreach (string assembly in (string[]) ["Abi", "Annotations", "Engine", "Hosting", "Lua", "Lua.Interop"]) + { + File.WriteAllText(Path.Combine(bundle, "CheatEngine.SDK." + assembly + ".dll"), string.Empty); + } + + File.WriteAllText(Path.Combine(bundle, "Plugin.deps.json"), PackageConsumerDeps); + File.WriteAllText(Path.Combine(bundle, "Plugin.runtimeconfig.json"), "{}"); + string bridge = Path.Combine(bundle, "cheatengine-sdk-lua-bridge.dll"); + File.WriteAllText(bridge, "the packaged bridge"); + return Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(bridge))); + } + + // Compiles the plugin with the entry point the package's generator emits (internal static class CESDK.CESDK) and + // entry-point variants, then runs the entry-point and closure checks on them. + private static string EntryPointAndClosureScript(string bundle, string variants, string bridgeSha256) + { + return ModuleImport + $$""" + function New-Assembly([string] $Path, [string] $Source) { Add-Type -TypeDefinition $Source -OutputAssembly $Path -OutputType Library } + New-Assembly {{PowerShellProcess.Quote(Path.Combine(bundle, "Plugin.dll"))}} 'namespace CESDK { internal static class CESDK { public static int CEPluginInitialize(System.IntPtr args, int opaqueArgument) { return 1; } } }' + $variants = [ordered]@{ + publicStatic = 'namespace CESDK { public static class CESDK { public static int CEPluginInitialize(System.IntPtr args, int opaqueArgument) { return 1; } } }' + notStatic = 'namespace CESDK { internal class CESDK { public static int CEPluginInitialize(System.IntPtr args, int opaqueArgument) { return 1; } } }' + privateMethod = 'namespace CESDK { internal static class CESDK { private static int CEPluginInitialize(System.IntPtr args, int opaqueArgument) { return 1; } } }' + wrongSignature = 'namespace CESDK { internal static class CESDK { public static int CEPluginInitialize(int args, int opaqueArgument) { return 1; } } }' + otherNamespace = 'namespace Plugin { internal static class CESDK { public static int CEPluginInitialize(System.IntPtr args, int opaqueArgument) { return 1; } } }' + } + $entryPoints = [ordered]@{ internalStatic = Test-EntryPointExport -AssemblyPath {{PowerShellProcess.Quote(Path.Combine(bundle, "Plugin.dll"))}} } + foreach ($name in $variants.Keys) { + $path = Join-Path {{PowerShellProcess.Quote(variants)}} "$name.dll" + New-Assembly $path $variants[$name] + $entryPoints[$name] = Test-EntryPointExport -AssemblyPath $path + } + [ordered]@{ + accepted = @(Test-QualificationBundleClosure -BundleDirectory {{PowerShellProcess.Quote(bundle)}} -PluginFileName 'Plugin.dll' -PackagedBridgeSha256 '{{bridgeSha256}}' -PackageLibrary 'CheatEngine.SDK/2.0.0-alpha.0.12') + otherPackage = @(Test-QualificationBundleClosure -BundleDirectory {{PowerShellProcess.Quote(bundle)}} -PluginFileName 'Plugin.dll' -PackagedBridgeSha256 '{{bridgeSha256}}' -PackageLibrary 'CheatEngine.SDK/2.0.0-alpha.0.13') + entryPoints = $entryPoints + } | ConvertTo-Json -Compress -Depth 4 + """; + } + private static JsonElement Closure(string bundle, string packagedBridge) { return RunJson(ModuleImport + From 52fc98157e67fc4dfd7eac3e37e523c308ca22bc Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:25:09 +0200 Subject: [PATCH 037/199] Require an observed removal of plugin A in the Q09 pass rules The C4 coexistence rules checked the identities and that B answered after the removal step, but pingA (called after the operator unticks A) was only observed, and the removal step accepted any input. A run in which A was never removed still produced Passed/Functional: a false C4 receipt for "activation et retrait independants" (analyses/20 Q09; A00-31, A20-01). Q09.a and Q09.b now require pingA to fail after the removal (CoexistencePluginA unregisters its Lua functions in OnDisable) and ask the operator to confirm the removal with y; any other answer makes the run Inconclusive (no receipt). The identity observations are kept. Q07 also checks the post-pump status step (ok, context.phase Enabled), so a disable that Cheat Engine applies after the 20-second pump cannot pass as RefusalVerified. A module test evaluates both rules on synthetic step results. --- docs/qualification/local-protocol.md | 7 +-- eng/qualification/README.md | 6 ++- eng/qualification/scenarios.json | 12 ++++-- .../LocalQualificationRunnerTests.cs | 43 +++++++++++++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/docs/qualification/local-protocol.md b/docs/qualification/local-protocol.md index 8b758cff..a37f878d 100644 --- a/docs/qualification/local-protocol.md +++ b/docs/qualification/local-protocol.md @@ -84,9 +84,9 @@ opened in Cheat Engine) is satisfied. | Q05 | C3 | LiveProbe | x64 | Status, operator unticks and ticks the plugin, status again, operator confirms the name | new epoch, same plugin assembly, stable name | | Q05.a | C3 | LiveProbeNonAscii | x64 | Status; operator records how the name is shown and whether the list is intact | plugin loads and the operator confirms nothing is corrupted | | Q06 | C3 | LiveProbe, fault `OnEnable` | x64 | Load with the fault switch; the runner removes it; operator re-enables | first enable failed and its commands are gone; re-enable succeeds and records `OnEnable@1` | -| Q07 | C3 | LiveProbe | x64 | Operator unticks the plugin while `ce77_live_probe_pump_messages(20)` runs | pump completes and the plugin stays enabled (nested disable refused); operator confirms the action | +| Q07 | C3 | LiveProbe | x64 | Operator unticks the plugin while `ce77_live_probe_pump_messages(20)` runs | pump completes and the plugin is still enabled after it (nested disable refused); operator confirms the action | | Q08 | C3 | LiveProbe, fault `OnDisable` | x64 | Operator unticks (OnDisable throws), the runner removes the switch, operator re-enables and judges | failure recorded (`OnDisable@1`) and no cleanly-disabled state shown while cleanup had failed | -| Q09.a | C4 | Coexistence A and B, one folder | none | Load both, record identities, operator removes A, B still answers, operator restores A | identities recorded; B works while A is removed; both work again | +| Q09.a | C4 | Coexistence A and B, one folder | none | Load both, record identities, operator removes A, B still answers, operator restores A | identities recorded; A no longer answers and B works while A is removed; both work again | | Q09.b | C4 | Coexistence A and B, two folders | none | Same as Q09.a | same | | Q14 | C3 | LiveProbe | x64 | Automated: `pcall(ce77_live_probe_throw_managed_exception)`, then status | catchable Lua error with the marker text; the next call works | | Q15 | C3 | LiveProbe | x64 | Callback prepared and called; operator unticks; the kept callback is called again | the kept callback fails with "released" | @@ -96,7 +96,8 @@ opened in Cheat Engine) is satisfied. | Q39 | C3 | — | — | None: `NotApplicable` by profile decision CPA-2 | — | | Q40 | C3 | LiveProbe (clean folder) | x64 | Status and host profile | plugin, Hosting assembly and bridge load from the bundle folder; bridge equals the packaged one | -A scenario whose operator step was not performed (for example Q07 without unticking) is **Inconclusive**: no receipt is +A scenario whose operator step was not performed (for example Q07 without unticking, or Q09 when plugin A could not be +removed) is **Inconclusive**: no receipt is written and the runner says so. ## LiveProbe authorization and fault switch diff --git a/eng/qualification/README.md b/eng/qualification/README.md index 42f823f1..ab7ea897 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -116,8 +116,10 @@ authorization manifest contain private data and must never be committed. (`Registry_diff_reports_value_names_only`); an incomplete bundle is refused (`Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry`) and a bundle built from the package, with the generated internal entry point, is accepted - (`Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_generated_internal_entry_point`); the recorded - content hash is the + (`Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_generated_internal_entry_point`); a coexistence + receipt passes only when plugin A was observably removed and Q07 only when the plugin is still enabled after the pump + (`Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_still_enabled_after_the_refused_disable`); the + recorded content hash is the lock-file value (`Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash`); only Cheat Engine's own executables count as another instance, never a process such as a `CheatEngine.*` test host (`Only_Cheat_Engine_executables_count_as_another_instance`). diff --git a/eng/qualification/scenarios.json b/eng/qualification/scenarios.json index f503d997..c94c6cb8 100644 --- a/eng/qualification/scenarios.json +++ b/eng/qualification/scenarios.json @@ -175,7 +175,9 @@ "checks": [ { "step": "pump", "ok": true }, { "step": "pump", "json": "enabledAfter", "equals": true }, - { "step": "pump", "json": "phaseAfter", "equals": "Enabled" } + { "step": "pump", "json": "phaseAfter", "equals": "Enabled" }, + { "step": "status", "ok": true }, + { "step": "status", "json": "context.phase", "equals": "Enabled" } ] } }, @@ -363,7 +365,7 @@ { "id": "loadB", "kind": "Lua", "action": "loadPlugin", "bundle": "CoexistenceSharedB" }, { "id": "identityA", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_identity" }, { "id": "identityB", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_identity" }, - { "id": "removeA", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK Coexistence Plugin A' only, and apply. Type anything to continue." }, + { "id": "removeA", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK Coexistence Plugin A' only, and apply. Type y once A is unticked and applied, n if you could not remove it." }, { "id": "pingB", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_ping" }, { "id": "pingA", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_ping" }, { "id": "restoreA", "kind": "Operator", "instruction": "Tick 'CheatEngine.SDK Coexistence Plugin A' again and apply. Type anything to continue." }, @@ -374,10 +376,12 @@ "passRule": { "kind": "Automated", "passKind": "Functional", + "requiresAnswer": { "step": "removeA", "answer": "y" }, "checks": [ { "step": "identityA", "ok": true }, { "step": "identityB", "ok": true }, { "step": "pingB", "ok": true }, + { "step": "pingA", "ok": false }, { "step": "pingA2", "ok": true }, { "step": "pingB2", "ok": true } ] @@ -395,7 +399,7 @@ { "id": "loadB", "kind": "Lua", "action": "loadPlugin", "bundle": "CoexistenceB" }, { "id": "identityA", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_identity" }, { "id": "identityB", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_identity" }, - { "id": "removeA", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK Coexistence Plugin A' only, and apply. Type anything to continue." }, + { "id": "removeA", "kind": "Operator", "instruction": "In Edit > Settings > Plugins untick 'CheatEngine.SDK Coexistence Plugin A' only, and apply. Type y once A is unticked and applied, n if you could not remove it." }, { "id": "pingB", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_b_ping" }, { "id": "pingA", "kind": "Lua", "action": "call", "function": "cheatengine_sdk_coexistence_a_ping" }, { "id": "restoreA", "kind": "Operator", "instruction": "Tick 'CheatEngine.SDK Coexistence Plugin A' again and apply. Type anything to continue." }, @@ -406,10 +410,12 @@ "passRule": { "kind": "Automated", "passKind": "Functional", + "requiresAnswer": { "step": "removeA", "answer": "y" }, "checks": [ { "step": "identityA", "ok": true }, { "step": "identityB", "ok": true }, { "step": "pingB", "ok": true }, + { "step": "pingA", "ok": false }, { "step": "pingA2", "ok": true }, { "step": "pingB2", "ok": true } ] diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs index c8988897..4d954bbd 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -341,6 +341,49 @@ public void Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_gene Directory.Delete(root, true); } } + [Fact] + public void Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_still_enabled_after_the_refused_disable() + { + JsonElement result = RunJson(ModuleImport + $$""" + $plan = Get-Content -LiteralPath {{PowerShellProcess.Quote(QualificationDocuments.Absolute(Scenarios))}} -Raw | ConvertFrom-Json -Depth 64 + function New-Step([string] $Id, [bool] $Ok, [string] $Json = '') { + $values = if ($Json) { @([pscustomobject]@{ type = 'string'; value = $Json }) } else { @() } + [pscustomobject]@{ id = $Id; ok = $Ok; values = $values } + } + function Get-Status($Scenario, $Steps, $Answers) { + $outcome = Resolve-QualificationOutcome -Scenario $Scenario -Steps $Steps -Answers $Answers + "$($outcome.status) $($outcome.passKind)".Trim() + } + $result = [ordered]@{} + foreach ($id in 'Q09.a', 'Q09.b') { + $scenario = @($plan.scenarios | Where-Object id -eq $id)[0] + $steps = [ordered]@{} + foreach ($name in 'identityA', 'identityB', 'pingB', 'pingA2', 'pingB2') { $steps[$name] = New-Step $name $true } + $steps.pingA = New-Step 'pingA' $false + $result["$id removed"] = Get-Status $scenario $steps ([ordered]@{ removeA = 'y' }) + $steps.pingA = New-Step 'pingA' $true + $result["$id stillLoaded"] = Get-Status $scenario $steps ([ordered]@{ removeA = 'y' }) + $result["$id notRemoved"] = Get-Status $scenario $steps ([ordered]@{ removeA = 'n' }) + } + $q07 = @($plan.scenarios | Where-Object id -eq 'Q07')[0] + $steps = [ordered]@{ pump = New-Step 'pump' $true '{"enabledAfter":true,"phaseAfter":"Enabled"}'; status = New-Step 'status' $true '{"context":{"phase":"Enabled"} }' } + $result['Q07 refused'] = Get-Status $q07 $steps ([ordered]@{ acted = 'y' }) + $steps.status = New-Step 'status' $true '{"context":{"phase":"Disabled"} }' + $result['Q07 disabledAfterThePump'] = Get-Status $q07 $steps ([ordered]@{ acted = 'y' }) + $result | ConvertTo-Json -Compress + """); + + foreach (string id in (string[]) ["Q09.a", "Q09.b"]) + { + Assert.Equal("Passed Functional", result.GetProperty(id + " removed").GetString()); + Assert.Equal("Failed", result.GetProperty(id + " stillLoaded").GetString()); + Assert.Equal("Inconclusive", result.GetProperty(id + " notRemoved").GetString()); + } + + Assert.Equal("Passed RefusalVerified", result.GetProperty("Q07 refused").GetString()); + Assert.Equal("Failed", result.GetProperty("Q07 disabledAfterThePump").GetString()); + } + [Fact] public void Only_Cheat_Engine_executables_count_as_another_instance() { From a0285928c109ec079c1e25c018c2923e3d1db39c Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:25:19 +0200 Subject: [PATCH 038/199] Harden the runner work root, registry stage and operator prompts Review follow-ups on the local qualification runner (never run in CI): - Refuse a work root that overlaps -CheatEnginePath (robocopy /MIR into \sandbox would write into or mirror onto the installation) or that holds a non-ASCII character (the driver's event paths go through Cheat Engine's ANSI io.open, so the run would end in the watchdog). The git probe uses Path.Combine, which also works for a drive that does not exist. - Guard stage 11 and stage 12 separately: a failing cleanup step no longer skips the HKCU comparison, and any failure of the comparison or restore (including reg export) is exit 7 with the manual restore command, never the trap's exit 6. - Keep the first answer when a driver that autorun loaded again (after resetLuaState, Q18) asks for the same operator step, instead of prompting twice. - Build harnesses and the target with --disable-build-servers instead of running "dotnet build-server shutdown", which stopped every build server of the user (https://learn.microsoft.com/dotnet/core/tools/dotnet-publish#options). - Record the x64 and x86 "dotnet --list-runtimes", prefixed with the architecture, like the profile's dotnetRuntimesObserved. - Hash with Convert.ToHexString().ToLowerInvariant(): ToHexStringLower is a .NET 9 API and the scripts declare pwsh 7.4 (.NET 8). The docs state that a profile mismatch stops the run with no receipt (exit 4), that the watchdog is suspended while the operator is prompted, and the new work-root rules; a module test covers the work-root refusals. --- docs/qualification/local-protocol.md | 5 +- docs/qualification/support-profile.md | 4 +- .../Invoke-LocalQualification.ps1 | 97 ++++++++++++++----- eng/qualification/QualificationRunner.psm1 | 26 ++++- eng/qualification/README.md | 36 ++++--- .../LocalQualificationRunnerTests.cs | 30 ++++++ 6 files changed, 152 insertions(+), 46 deletions(-) diff --git a/docs/qualification/local-protocol.md b/docs/qualification/local-protocol.md index a37f878d..cf25018b 100644 --- a/docs/qualification/local-protocol.md +++ b/docs/qualification/local-protocol.md @@ -24,7 +24,10 @@ Nothing in this protocol runs in CI, and a C1/C2 result is never a substitute fo - **Never elevated.** Run PowerShell as your normal user; Cheat Engine then starts as the invoking user. - **Sandbox only.** The installation under `%ProgramFiles%\Cheat Engine` is read and copied, never written, launched or configured. The runner mirrors it into `\sandbox`, compares every file by SHA-256, and starts - `cheatengine-x86_64.exe` from there; never the launcher or the SSE4-AVX2 executable. + `cheatengine-x86_64.exe` from there; never the launcher or the SSE4-AVX2 executable. The work root must not overlap + the installation and must be an ASCII path (Cheat Engine's Lua file API is ANSI); the runner refuses otherwise. +- **Stay at the prompt.** The watchdog is suspended while the runner waits for your answer to an operator step; if + Cheat Engine hangs meanwhile, close it yourself and answer `n`. - **Registry.** Every copy of Cheat Engine shares `HKCU\Software\Cheat Engine`. The runner exports it before and after each scenario, records the difference as counts and value names, and restores it only when the difference is non-empty. If another Cheat Engine instance runs, stop it first; with `-AllowOtherCheatEngineInstances` the runner never diff --git a/docs/qualification/support-profile.md b/docs/qualification/support-profile.md index 207d170c..c68d6e94 100644 --- a/docs/qualification/support-profile.md +++ b/docs/qualification/support-profile.md @@ -87,7 +87,9 @@ unknown. - Editing that file is never harmless or universal: it changes the runtime of every managed plugin of the installation. Nothing in this repository edits an installed Cheat Engine, and no guide treats such an edit as a setup step. - The qualification runner copies the installation into a sandbox and records the runtime configuration hash in every - receipt; a different hash makes the run `NotApplicable`, never `Passed`. + receipt. A different hash (or any other host fact that differs from this profile) stops the runner before Cheat + Engine starts, and no receipt is written; a receipt produced by other means for another host could only be + `NotApplicable` with a justification, never `Passed`. - .NET runtimes observed on the qualification machine: x64 `Microsoft.NETCore.App`, `Microsoft.WindowsDesktop.App` and `Microsoft.AspNetCore.App` 10.0.8, 10.0.11 and 10.0.12, plus x64 `Microsoft.NETCore.App` and `Microsoft.WindowsDesktop.App` 8.0.31; x86 `Microsoft.NETCore.App` and `Microsoft.WindowsDesktop.App` 6.0.36 only. A diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 index 1bba5ba0..bb5abcc4 100644 --- a/eng/qualification/Invoke-LocalQualification.ps1 +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -212,7 +212,13 @@ function Invoke-Preflight { try { $facts.dotnetSdk = (Invoke-Native -FilePath 'dotnet' -ArgumentList @('--version') | Select-Object -First 1).Trim() } finally { Pop-Location } $facts.expectedDotnetSdk = $globalJson.sdk.version - $facts.dotnetRuntimes = @(Invoke-Native -FilePath 'dotnet' -ArgumentList @('--list-runtimes') | ForEach-Object { ($_ -replace '\s*\[.*\]\s*$', '').Trim() } | Where-Object { $_ }) + # Both architectures, prefixed like the profile's dotnetRuntimesObserved (x86 runtimes live in their own dotnet.exe). + $runtimes = @(Invoke-Native -FilePath 'dotnet' -ArgumentList @('--list-runtimes') | ForEach-Object { 'x64 ' + ($_ -replace '\s*\[.*\]\s*$', '').Trim() } | Where-Object { $_ -ne 'x64 ' }) + $x86Dotnet = Join-Path ${env:ProgramFiles(x86)} 'dotnet\dotnet.exe' + if (${env:ProgramFiles(x86)} -and (Test-Path -LiteralPath $x86Dotnet -PathType Leaf)) { + $runtimes += @(Invoke-Native -FilePath $x86Dotnet -ArgumentList @('--list-runtimes') | ForEach-Object { 'x86 ' + ($_ -replace '\s*\[.*\]\s*$', '').Trim() } | Where-Object { $_ -ne 'x86 ' }) + } + $facts.dotnetRuntimes = $runtimes $facts.osVersion = [System.Environment]::OSVersion.VersionString return $facts } @@ -380,8 +386,10 @@ $($compile -join "`n") $sdk = (Invoke-Native -FilePath 'dotnet' -ArgumentList @('--version') | Select-Object -First 1).Trim() $pinned = (Get-Content -LiteralPath (Join-Path $RepositoryRoot 'global.json') -Raw | ConvertFrom-Json).sdk.version if ($sdk -ne $pinned) { throw "The bundle build directory resolves SDK $sdk, global.json pins $pinned." } - $null = Invoke-Native -FilePath 'dotnet' -ArgumentList @('restore', $projectPath, '--configfile', (Join-Path $buildDirectory 'NuGet.Config'), '--packages', $packagesDirectory, '--no-http-cache', '--force-evaluate', '--nologo') - $buildOutput = Invoke-Native -FilePath 'dotnet' -ArgumentList @('publish', $projectPath, '-c', 'Release', '--no-restore', '--nologo', '-o', $bundleDirectory) + # --disable-build-servers: no compiler or MSBuild server outlives the build, so the runner never has to shut down + # the operator's own build servers (https://learn.microsoft.com/dotnet/core/tools/dotnet-publish#options). + $null = Invoke-Native -FilePath 'dotnet' -ArgumentList @('restore', $projectPath, '--configfile', (Join-Path $buildDirectory 'NuGet.Config'), '--packages', $packagesDirectory, '--no-http-cache', '--force-evaluate', '--disable-build-servers', '--nologo') + $buildOutput = Invoke-Native -FilePath 'dotnet' -ArgumentList @('publish', $projectPath, '-c', 'Release', '--no-restore', '--disable-build-servers', '--nologo', '-o', $bundleDirectory) } finally { Pop-Location @@ -465,7 +473,7 @@ function Publish-QualificationTarget { $output = Join-Path $RunDirectory "target-$Architecture" if ($PSCmdlet.ShouldProcess($output, "publish the qualification target for win-$Architecture")) { - $null = Invoke-Native -FilePath 'dotnet' -ArgumentList @('publish', (Join-Path $RepositoryRoot 'tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj'), '-c', 'Release', '-r', "win-$Architecture", '-o', $output, '--nologo') + $null = Invoke-Native -FilePath 'dotnet' -ArgumentList @('publish', (Join-Path $RepositoryRoot 'tests/CheatEngine.SDK.QualificationTarget/CheatEngine.SDK.QualificationTarget.csproj'), '-c', 'Release', '-r', "win-$Architecture", '-o', $output, '--disable-build-servers', '--nologo') } return Join-Path $output 'CheatEngine.SDK.QualificationTarget.exe' @@ -627,7 +635,17 @@ function Invoke-CheatEngineSession { $offset = $read.offset foreach ($line in $read.lines) { $session.events.Add($line) - if ($line.kind -eq 'AwaitOperator') { Invoke-OperatorStep -Request ($line.message | ConvertFrom-Json) -Definition $Definition -HandshakeDirectory $handshakeDirectory -Answers $session.answers -FaultFile $FaultFile -Events $session.events } + if ($line.kind -eq 'AwaitOperator') { + $request = $line.message | ConvertFrom-Json + if ($session.answers.Contains([string] $request.id)) { + # A driver that autorun loaded again (for example after resetLuaState) resumes at the pending + # operator step and asks again; its handshake file already holds the answer, so keep it. + $session.events.Add([pscustomobject]@{ tMs = -1; source = 'Runner'; kind = 'AwaitOperatorRepeated'; message = "Step $($request.step) ($($request.id)) was requested again by a resumed driver; the recorded answer is kept." }) + } + else { + Invoke-OperatorStep -Request $request -Definition $Definition -HandshakeDirectory $handshakeDirectory -Answers $session.answers -FaultFile $FaultFile -Events $session.events + } + } elseif ($line.kind -eq 'StepResult') { Write-Information " step $($line.message)" } } Start-Sleep -Milliseconds 200 @@ -681,7 +699,7 @@ $qualifiable = @($supportProfile.profiles | Where-Object { $_.id -eq $ProfileId $matrix = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'docs/qualification/matrix.json') -Raw | ConvertFrom-Json -Depth 64 $plan = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'scenarios.json') -Raw | ConvertFrom-Json -Depth 64 -$workRootProblem = Test-QualificationWorkRoot -WorkRoot $WorkRoot -RepositoryRoot $RepositoryRoot +$workRootProblem = Test-QualificationWorkRoot -WorkRoot $WorkRoot -RepositoryRoot $RepositoryRoot -CheatEnginePath $CheatEnginePath if ($workRootProblem) { Exit-Qualification -Code 5 -Reason $workRootProblem } $selected = [System.Collections.Generic.List[object]]::new() @@ -773,9 +791,6 @@ try { $targetExecutables[$architecture] = Publish-QualificationTarget -RepositoryRoot $RepositoryRoot -RunDirectory $runDirectory -Architecture $architecture } - # The builds above leave compiler and MSBuild servers behind; stop them so they do not load the machine during the runs. - $null = Invoke-Native -FilePath 'dotnet' -ArgumentList @('build-server', 'shutdown') - $tree = (Invoke-Native -FilePath 'git' -ArgumentList @('-C', $RepositoryRoot, 'rev-parse', 'HEAD^{tree}') | Select-Object -First 1).Trim() $commit = (Invoke-Native -FilePath 'git' -ArgumentList @('-C', $RepositoryRoot, 'rev-parse', 'HEAD') | Select-Object -First 1).Trim() $scriptSha256 = Get-QualificationTextSha256 -Text ([System.IO.File]::ReadAllText($PSCommandPath)) @@ -795,6 +810,8 @@ try { $registryAfter = Join-Path $sessionDirectory 'hkcu-after.reg' $session = $null $registry = $null + $existedBefore = $false + $registryCaptured = $false Write-Information "== $($definition.id) ($($definition.level))" try { # Stage 7, targets and authorization. @@ -820,6 +837,7 @@ try { # Stage 8, HKCU before. $existedBefore = Export-CheatEngineRegistry -Path $registryBefore if (-not $existedBefore) { [System.IO.File]::WriteAllText($registryBefore, '', [System.Text.Encoding]::Unicode) } + $registryCaptured = $true # Stages 9-10, driver and launch. $ceStart = $sessionClock.ElapsedMilliseconds @@ -828,27 +846,54 @@ try { $runnerEvents.Add([pscustomobject]@{ tMs = $sessionClock.ElapsedMilliseconds; source = 'Runner'; kind = 'CheatEngineExited'; message = "exit code $($session.exitCode); killed by watchdog: $($session.killed)" }) } finally { - # Stage 11, cleanup. - Stop-QualificationTarget -Target $target - foreach ($stray in @(Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith($sandbox, [System.StringComparison]::OrdinalIgnoreCase) })) { Stop-Process -Id $stray.Id -Force } - foreach ($file in @($manifestPath, $faultFile)) { if ($file -and (Test-Path -LiteralPath $file)) { Remove-Item -LiteralPath $file -Force } } - Remove-Item Env:CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT -ErrorAction SilentlyContinue - Remove-Item Env:CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE -ErrorAction SilentlyContinue - - # Stage 12, HKCU after, compare, restore only a non-empty difference. - $existsAfter = Export-CheatEngineRegistry -Path $registryAfter - if (-not $existsAfter) { [System.IO.File]::WriteAllText($registryAfter, '', [System.Text.Encoding]::Unicode) } - $diff = Compare-RegistrySnapshot -Before (Read-RegistryExport -Path $registryBefore) -After (Read-RegistryExport -Path $registryAfter) -RootKey 'HKEY_CURRENT_USER\Software\Cheat Engine' - $registry = [ordered]@{ key = $RegistryKey; exportBeforeSha256 = Get-QualificationFileSha256 -Path $registryBefore; exportAfterSha256 = Get-QualificationFileSha256 -Path $registryAfter; restored = $false; diff = $diff } - if (($diff.added + $diff.removed + $diff.changed) -gt 0) { - if ((Get-CheatEngineProcess).Count -gt 0) { - Exit-Qualification -Code 7 -Reason "HKCU changed ($($diff.valueNames -join ', ')) while another Cheat Engine instance runs; restore it by hand after closing it: reg delete `"$RegistryKey`" /f; reg import `"$registryBefore`"" + # Stage 11, cleanup. A failing cleanup step is reported but never skips the HKCU comparison below. + try { + Stop-QualificationTarget -Target $target + foreach ($stray in @(Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith($sandbox, [System.StringComparison]::OrdinalIgnoreCase) })) { Stop-Process -Id $stray.Id -Force } + foreach ($file in @($manifestPath, $faultFile)) { if ($file -and (Test-Path -LiteralPath $file)) { Remove-Item -LiteralPath $file -Force } } + } + catch { + [System.Console]::Error.WriteLine("Invoke-LocalQualification.ps1: cleanup of $($definition.id) failed: $($_.Exception.Message)") + $exitCode = 6 + } + finally { + Remove-Item Env:CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT -ErrorAction SilentlyContinue + Remove-Item Env:CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE -ErrorAction SilentlyContinue + } + + # Stage 12, HKCU after, compare, restore only a non-empty difference. Skipped when Cheat Engine never + # started (no export before). Any failure here is the critical exit 7 with the manual restore command, + # never the generic exit 6 of the script trap. + if ($registryCaptured) { + $manualRestore = "Backup: $registryBefore; command: reg delete `"$RegistryKey`" /f; reg import `"$registryBefore`"" + $otherInstances = $false + try { + $existsAfter = Export-CheatEngineRegistry -Path $registryAfter + if (-not $existsAfter) { [System.IO.File]::WriteAllText($registryAfter, '', [System.Text.Encoding]::Unicode) } + $diff = Compare-RegistrySnapshot -Before (Read-RegistryExport -Path $registryBefore) -After (Read-RegistryExport -Path $registryAfter) -RootKey 'HKEY_CURRENT_USER\Software\Cheat Engine' + $registry = [ordered]@{ key = $RegistryKey; exportBeforeSha256 = Get-QualificationFileSha256 -Path $registryBefore; exportAfterSha256 = Get-QualificationFileSha256 -Path $registryAfter; restored = $false; diff = $diff } + $otherInstances = ($diff.added + $diff.removed + $diff.changed) -gt 0 -and (Get-CheatEngineProcess).Count -gt 0 + if (-not $otherInstances -and ($diff.added + $diff.removed + $diff.changed) -gt 0) { + $registry.restored = Restore-CheatEngineRegistry -Before $registryBefore -ExistedBefore $existedBefore -Verify (Join-Path $sessionDirectory 'hkcu-restored.reg') + } + } + catch { + Exit-Qualification -Code 7 -Reason "HKCU could not be compared or restored after $($definition.id) ($($_.Exception.Message)). $manualRestore" + } + + if ($otherInstances) { + Exit-Qualification -Code 7 -Reason "HKCU changed ($($diff.valueNames -join ', ')) while another Cheat Engine instance runs; restore it by hand after closing it. $manualRestore" } - $registry.restored = Restore-CheatEngineRegistry -Before $registryBefore -ExistedBefore $existedBefore -Verify (Join-Path $sessionDirectory 'hkcu-restored.reg') - if (-not $registry.restored) { Exit-Qualification -Code 7 -Reason "HKCU restore could not be verified. Backup: $registryBefore; command: reg delete `"$RegistryKey`" /f; reg import `"$registryBefore`"" } + if (($diff.added + $diff.removed + $diff.changed) -gt 0 -and -not $registry.restored) { Exit-Qualification -Code 7 -Reason "HKCU restore could not be verified. $manualRestore" } } } + if ($null -eq $registry -or $null -eq $session) { + # Unreachable in practice: an error before Cheat Engine started propagates to the trap (exit 6) after the + # finally block above. + Exit-Qualification -Code 6 -Reason "Scenario $($definition.id) produced no session." + } + # Stage 13, redaction, outcome, receipt. $redactionPaths = [ordered]@{ '' = $sandbox; '' = $WorkRoot; '' = $RepositoryRoot; '' = $CheatEnginePath } foreach ($name in $bundles.Keys) { $redactionPaths[""] = $bundles[$name].directory } diff --git a/eng/qualification/QualificationRunner.psm1 b/eng/qualification/QualificationRunner.psm1 index f9902f7a..815c134c 100644 --- a/eng/qualification/QualificationRunner.psm1 +++ b/eng/qualification/QualificationRunner.psm1 @@ -46,7 +46,8 @@ function Get-QualificationTextSha256 { param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Text) $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text.Replace("`r`n", "`n")) - return [System.Convert]::ToHexStringLower([System.Security.Cryptography.SHA256]::HashData($bytes)) + # Convert.ToHexString (.NET 5+) rather than ToHexStringLower (.NET 9): the scripts require pwsh 7.4, which runs on .NET 8. + return [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() } function ConvertFrom-RegistryExport { @@ -628,23 +629,40 @@ function Get-RestoredPackageContentHash { function Test-QualificationWorkRoot { <# .SYNOPSIS - Returns why a work root is unsafe (inside a git work tree, or below the repository's parent directory), or $null. + Returns why a work root is unsafe, or $null: it holds a non-ASCII character (the driver's paths go through + Cheat Engine's ANSI io.open), it overlaps the Cheat Engine directory (the sandbox mirror would write into the + installation or mirror it onto itself), it lies below the repository's parent directory, or inside a git work tree. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $WorkRoot, - [Parameter(Mandatory)] [string] $RepositoryRoot + [Parameter(Mandatory)] [string] $RepositoryRoot, + [string] $CheatEnginePath = '' ) $full = [System.IO.Path]::GetFullPath($WorkRoot).TrimEnd('\') + '\' + if ($full -match '[^\x20-\x7E]') { + return "The work root '$WorkRoot' contains a non-ASCII character; the driver writes its events through Cheat Engine's ANSI file API. Pass -WorkRoot with an ASCII path." + } + + if ($CheatEnginePath) { + $installation = [System.IO.Path]::GetFullPath($CheatEnginePath).TrimEnd('\') + '\' + if ($full.StartsWith($installation, [System.StringComparison]::OrdinalIgnoreCase) -or $installation.StartsWith($full, [System.StringComparison]::OrdinalIgnoreCase)) { + return "The work root '$WorkRoot' overlaps the Cheat Engine directory '$CheatEnginePath'; the sandbox must be a separate copy." + } + } + $repositoryParent = [System.IO.Path]::GetFullPath((Join-Path $RepositoryRoot '..')).TrimEnd('\') + '\' if ($full.StartsWith($repositoryParent, [System.StringComparison]::OrdinalIgnoreCase)) { return "The work root '$WorkRoot' is below the repository's parent directory; bundles must not see workspace files." } for ($directory = [System.IO.DirectoryInfo]::new($full); $null -ne $directory; $directory = $directory.Parent) { - if (Test-Path -LiteralPath (Join-Path $directory.FullName '.git')) { + # .git is a directory in a clone and a file in a linked worktree; Path.Combine also works for a drive that does + # not exist yet (Join-Path does not). + $gitEntry = [System.IO.Path]::Combine($directory.FullName, '.git') + if ([System.IO.Directory]::Exists($gitEntry) -or [System.IO.File]::Exists($gitEntry)) { return "The work root '$WorkRoot' is inside the git work tree '$($directory.FullName)'." } } diff --git a/eng/qualification/README.md b/eng/qualification/README.md index ab7ea897..2c79f2d6 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -26,7 +26,9 @@ operator's Cheat Engine settings changed. 2. **Preflight** (read-only). Hashes `cheatengine-x86_64.exe`, `lua53-64.dll`, `ce.runtimeconfig.json` and `celua.txt`, reads the file version and PE machine and compares them with [`support-profile.json`](../../docs/qualification/support-profile.json); refuses an elevated runner, another Cheat Engine instance, build processes and a dirty tree unless allowed; checks - that `dotnet --version` equals `global.json`; records `dotnet --list-runtimes`. + that `dotnet --version` equals `global.json`; records `dotnet --list-runtimes` of the x64 and, when installed, the x86 + `dotnet`, each entry prefixed with its architecture. A host that differs from the profile stops the run here (exit 4): + no receipt is ever written for another host. 3. **Mutex** `Global\ce-lab` (an abandoned mutex is taken over). 4. **Sandbox.** `robocopy /MIR` of the installation into `\sandbox`, then a SHA-256 comparison of every file; a stale driver is removed. @@ -34,11 +36,12 @@ operator's Cheat Engine settings changed. throw-away consumer project with empty `Directory.Build.*` and `Directory.Packages.props`, a copy of `global.json`, `Compile` items for the harness sources, a `PackageReference` to the exact package version read from its `.nuspec`, a `NuGet.Config` with `` and package source mapping, and an isolated `NUGET_PACKAGES`; restored with - `--no-http-cache --force-evaluate`, published, then checked: plugin with a static `CESDK.CESDK` class, public or - internal as the package's entry-point generator emits it, and a public static `CEPluginInitialize(nint, int)` (read - with System.Reflection.Metadata), the six SDK assemblies, a `.deps.json` whose only `project` library is the plugin's - own root entry, that lists `CheatEngine.SDK/` as a `package` and holds no absolute path, - `.runtimeconfig.json`, and the bridge equal to the package's `build/native` copy. Q09.a merges A and B into one + `--no-http-cache --force-evaluate`, published with `--disable-build-servers` (no compiler or MSBuild server outlives + the build, so the runner never shuts down the operator's own servers), then checked: plugin with a static + `CESDK.CESDK` class, public or internal as the package's entry-point generator emits it, and a public static + `CEPluginInitialize(nint, int)` (read with System.Reflection.Metadata), the six SDK assemblies, a `.deps.json` whose + only `project` library is the plugin's own root entry, that lists `CheatEngine.SDK/` as a `package` and holds + no absolute path, `.runtimeconfig.json`, and the bridge equal to the package's `build/native` copy. Q09.a merges A and B into one folder and refuses a same-named file with different bytes. Each bundle gets `bundle-manifest..json` (every file with its SHA-256, and the build warnings). 6. **Package and bridge identity.** SHA-256 of the `.nupkg`; the NuGet content hash from the isolated restore's @@ -50,11 +53,15 @@ operator's Cheat Engine settings changed. 9. **Driver.** Generates `autorun\zz_cesdk_qualification.lua` in the sandbox from the template with the steps, bundle paths and target PIDs. 10. **Launch.** Starts the sandbox `cheatengine-x86_64.exe`, not elevated, and serves operator steps through handshake - files; the watchdog (`-CeTimeoutSeconds`) kills Cheat Engine and fails the scenario. + files; the watchdog (`-CeTimeoutSeconds`) kills Cheat Engine and fails the scenario. The watchdog is checked + between driver events: while the runner waits at an operator prompt it is suspended, and the operator, who is + present by definition, closes a hung Cheat Engine. A driver that autorun loads again (for example after + `resetLuaState()`) resumes at the pending operator step; the runner keeps the first answer and does not ask twice. 11. **Cleanup** (always): stops the target and stray sandbox processes, removes the driver, the manifest, the fault - switch and the environment variables. + switch and the environment variables. A failing cleanup step is reported (exit 6) and never skips stage 12. 12. **HKCU after.** Exports, compares by value names, restores only a non-empty difference with no other Cheat Engine - running, and verifies the restore (exit 7 otherwise, with the manual command and the backup path). + running, and verifies the restore. Any failure of this stage, including a failing `reg export`, is exit 7 with the + manual command and the backup path, never the generic exit 6. 13. **Redaction and receipt.** Replaces the work root, sandbox, bundles, repository, installation, user and machine names with placeholders, bounds the event log, evaluates the pass rule and writes `.json` and `.events.json` (receipt id `R---`). @@ -72,7 +79,7 @@ operator's Cheat Engine settings changed. | `-PullRequest `, `-HeadSha ` | Pull request identity recorded in receipts (both or neither). | | `-Operator ` | GitHub handle recorded in receipts; required for receipts. | | `-CheatEnginePath ` | The installation to copy; default `%ProgramFiles%\Cheat Engine`. Read and copied only. | -| `-WorkRoot ` | Default `%LOCALAPPDATA%\CheatEngineNet\qualification`; refused inside a git work tree or below the repository's parent directory. | +| `-WorkRoot ` | Default `%LOCALAPPDATA%\CheatEngineNet\qualification`; refused when it holds a non-ASCII character (the driver writes through Cheat Engine's ANSI file API), overlaps `-CheatEnginePath`, lies inside a git work tree or below the repository's parent directory. | | `-CeTimeoutSeconds ` | Watchdog per scenario session (default 900). | | `-MutexTimeoutMinutes ` | How long to wait for `Global\ce-lab` (default 30). | | `-PreflightOnly` | Stage 2 only; prints the preflight record and exits 0 when the host matches the profile. | @@ -116,10 +123,11 @@ authorization manifest contain private data and must never be committed. (`Registry_diff_reports_value_names_only`); an incomplete bundle is refused (`Bundle_closure_check_rejects_a_missing_bridge_or_a_workspace_project_entry`) and a bundle built from the package, with the generated internal entry point, is accepted - (`Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_generated_internal_entry_point`); a coexistence - receipt passes only when plugin A was observably removed and Q07 only when the plugin is still enabled after the pump - (`Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_still_enabled_after_the_refused_disable`); the - recorded content hash is the + (`Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_generated_internal_entry_point`); an unsafe work + root is refused (`Work_root_is_refused_when_it_overlaps_Cheat_Engine_holds_non_ASCII_or_sees_the_workspace`); a + coexistence receipt passes only when plugin A was observably removed and Q07 only when the plugin is still enabled + after the pump (`Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_still_enabled_after_the_refused_disable`); + the recorded content hash is the lock-file value (`Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash`); only Cheat Engine's own executables count as another instance, never a process such as a `CheatEngine.*` test host (`Only_Cheat_Engine_executables_count_as_another_instance`). diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs index 4d954bbd..9f56f9c3 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -341,6 +341,36 @@ public void Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_gene Directory.Delete(root, true); } } + [Fact] + public void Work_root_is_refused_when_it_overlaps_Cheat_Engine_holds_non_ASCII_or_sees_the_workspace() + { + string repository = Infrastructure.RepositoryRoot.Path; + JsonElement result = RunJson(ModuleImport + $$""" + $repository = {{PowerShellProcess.Quote(repository)}} + $cases = [ordered]@{ + separate = @('Q:\ce-lab\work', 'Q:\Cheat Engine') + insideInstallation = @('Q:\Cheat Engine\qualification', 'Q:\Cheat Engine') + containsInstallation = @('Q:\lab', 'Q:\lab\Cheat Engine\') + sameNamePrefix = @('Q:\Cheat Engine 2\work', 'Q:\Cheat Engine') + nonAscii = @("Q:\lab\J$([char] 0xE9)r$([char] 0xF4)me\work", 'Q:\Cheat Engine') + workspace = @((Join-Path $repository '..\qualification-work'), 'Q:\Cheat Engine') + } + $result = [ordered]@{} + foreach ($name in $cases.Keys) { + $problem = Test-QualificationWorkRoot -WorkRoot $cases[$name][0] -RepositoryRoot $repository -CheatEnginePath $cases[$name][1] + $result[$name] = if ($null -eq $problem) { 'accepted' } else { $problem } + } + $result | ConvertTo-Json -Compress + """); + + Assert.Equal("accepted", result.GetProperty("separate").GetString()); + Assert.Equal("accepted", result.GetProperty("sameNamePrefix").GetString()); + Assert.Contains("overlaps the Cheat Engine directory", result.GetProperty("insideInstallation").GetString(), StringComparison.Ordinal); + Assert.Contains("overlaps the Cheat Engine directory", result.GetProperty("containsInstallation").GetString(), StringComparison.Ordinal); + Assert.Contains("non-ASCII", result.GetProperty("nonAscii").GetString(), StringComparison.Ordinal); + Assert.Contains("repository's parent directory", result.GetProperty("workspace").GetString(), StringComparison.Ordinal); + } + [Fact] public void Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_still_enabled_after_the_refused_disable() { From f3a6e962fa3836402386058d0c220dadd2e2be6f Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:25:27 +0200 Subject: [PATCH 039/199] Pin the live probe status test to an injected fault decision Status_json_reports_the_fault_switch_decision_and_the_assembly_identities compared faultInjection.stage with LiveProbeFaultInjection.Current, the same process-wide value the report serializes, so it could not fail. It now serializes a snapshot with a pinned OnDisable decision, reason, file flag and injected-stage list and checks each field. The README states the one exception to "inert unless authorized": the two status commands serialize process-local facts, touch no target and make no Lua call of their own, and a promise row names the tests. --- .../LiveProbeStatusTests.cs | 16 ++++++++++++---- tests/CheatEngine.SDK.LiveProbe/README.md | 8 +++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs index fed034eb..21d943ab 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/LiveProbeStatusTests.cs @@ -73,13 +73,21 @@ public void Status_without_an_enabled_context_reports_none_instead_of_zero_value [Fact] public void Status_json_reports_the_fault_switch_decision_and_the_assembly_identities() { - LiveProbeHostFacts host = Facts(0, 48, 1, 1); + // A pinned decision, not the process-wide LiveProbeFaultInjection state the report would otherwise echo. + LiveProbeFaultDecision decision = new(LiveProbeFaultStage.OnDisable, + "Fault switch selects OnDisable (liveprobe.fault.json, schema ce77-live-probe-fault-v1).", true); + LiveProbeStatusSnapshot snapshot = new(Facts(0, 48, 1, 1), 1, 0, false, 0, 0, null, true, "allowed", true, + "allowed", decision, ["OnEnable@1", "OnDisable@2"], 0, "none", "none", "none", "not prepared"); - using JsonDocument json = JsonDocument.Parse(LiveProbeState.GetStatusJson(host)); + using JsonDocument json = JsonDocument.Parse(LiveProbeStatusReport.ToJson(snapshot)); JsonElement fault = json.RootElement.GetProperty("faultInjection"); - Assert.Equal(LiveProbeFaultInjection.Current.Stage.ToString(), fault.GetProperty("stage").GetString()); - Assert.Equal(JsonValueKind.Array, fault.GetProperty("injected").ValueKind); + Assert.Equal("OnDisable", fault.GetProperty("stage").GetString()); + Assert.Equal(decision.Reason, fault.GetProperty("reason").GetString()); + Assert.True(fault.GetProperty("fileFound").GetBoolean()); + Assert.Equal(["OnEnable@1", "OnDisable@2"], + fault.GetProperty("injected").EnumerateArray().Select(static stage => stage.GetString()!), + StringComparer.Ordinal); JsonElement identity = json.RootElement.GetProperty("identity"); Assert.Equal("plugin.dll", identity.GetProperty("pluginAssemblyLocation").GetString()); Assert.Equal("hosting.dll", identity.GetProperty("hostingAssemblyLocation").GetString()); diff --git a/tests/CheatEngine.SDK.LiveProbe/README.md b/tests/CheatEngine.SDK.LiveProbe/README.md index d8b19fb1..3fadf1a4 100644 --- a/tests/CheatEngine.SDK.LiveProbe/README.md +++ b/tests/CheatEngine.SDK.LiveProbe/README.md @@ -22,7 +22,8 @@ CI never loads or runs it. ## Safety boundary -The probe is deliberately inert unless all of these are true: +The probe is deliberately inert unless all of these are true (the one exception is the two status commands below, which +only report process-local facts the probe already holds and act on nothing): 1. The process is x64 and its main executable is exactly CE `7.7.0.10621` x64, SHA-256 `9727076DA50924E4A097B49A02155E4B34759269C3017FF31375364B8826EB4D`. @@ -145,6 +146,11 @@ disposable target through its normal cleanup route. Do not force-unload assembli (`LiveProbeStatusTests.Status_reports_the_exports_size_and_the_raw_second_bootstrap_integer_without_interpretation`). - Without authorization the exception and pump hooks are inert, and the pump refuses a duration outside 1–60 seconds before any host call (`LiveProbeStatusTests`). +- The two status commands are deliberately not gated: they serialize process-local facts (bootstrap record, `PluginHost` + state, gate results, fault decisions, assembly identities), touch no target and make no Lua call of their own; an absent context is + reported as absent, never as zero values + (`LiveProbeStatusTests.Status_without_an_enabled_context_reports_none_instead_of_zero_values`, + `LiveProbeStatusTests.Status_json_reports_the_fault_switch_decision_and_the_assembly_identities`). - The fault switch is never read without authorization, selects exactly the requested stage, and ignores and reports an absent, unreadable or unknown switch (`LiveProbeFaultInjectionTests`). - Missing, locked or vanishing identity files are reported as typed outcomes, never as a crash From 7b8fe3b3e7988d8b42d9e0007df991c87ffbb86e Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:25:36 +0200 Subject: [PATCH 040/199] Declare the Q08 scope and the expected C3 outcome in the matrix The Q08 C1 cell passes on the SDK's own cleanup tests (failed-enable detach and callback detach stay incomplete and retryable). DisablePluginTests. OnDisable_throwing_is_logged_but_reports_TRUE_after_the_plugin_is_disabled stays untagged because it contradicts "pas de faux etat desactive proprement": after a throwing OnDisable the SDK logs the exception and reports TRUE. The C1 cell now says what it does not cover, and the C3 cell, whose scenario injects a throwing OnDisable, pre-declares expected Failed with that reason until the lifecycle decision changes. The local protocol row points at the pre-declaration. --- docs/qualification/local-protocol.md | 2 +- docs/qualification/matrix.json | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/qualification/local-protocol.md b/docs/qualification/local-protocol.md index cf25018b..48b53713 100644 --- a/docs/qualification/local-protocol.md +++ b/docs/qualification/local-protocol.md @@ -88,7 +88,7 @@ opened in Cheat Engine) is satisfied. | Q05.a | C3 | LiveProbeNonAscii | x64 | Status; operator records how the name is shown and whether the list is intact | plugin loads and the operator confirms nothing is corrupted | | Q06 | C3 | LiveProbe, fault `OnEnable` | x64 | Load with the fault switch; the runner removes it; operator re-enables | first enable failed and its commands are gone; re-enable succeeds and records `OnEnable@1` | | Q07 | C3 | LiveProbe | x64 | Operator unticks the plugin while `ce77_live_probe_pump_messages(20)` runs | pump completes and the plugin is still enabled after it (nested disable refused); operator confirms the action | -| Q08 | C3 | LiveProbe, fault `OnDisable` | x64 | Operator unticks (OnDisable throws), the runner removes the switch, operator re-enables and judges | failure recorded (`OnDisable@1`) and no cleanly-disabled state shown while cleanup had failed | +| Q08 | C3 | LiveProbe, fault `OnDisable` | x64 | Operator unticks (OnDisable throws), the runner removes the switch, operator re-enables and judges | failure recorded (`OnDisable@1`) and no cleanly-disabled state shown while cleanup had failed (pre-declared `Failed`, see the matrix) | | Q09.a | C4 | Coexistence A and B, one folder | none | Load both, record identities, operator removes A, B still answers, operator restores A | identities recorded; A no longer answers and B works while A is removed; both work again | | Q09.b | C4 | Coexistence A and B, two folders | none | Same as Q09.a | same | | Q14 | C3 | LiveProbe | x64 | Automated: `pcall(ce77_live_probe_throw_managed_exception)`, then status | catchable Lua error with the marker text; the next call works | diff --git a/docs/qualification/matrix.json b/docs/qualification/matrix.json index ffb77ed2..2ecc6bfd 100644 --- a/docs/qualification/matrix.json +++ b/docs/qualification/matrix.json @@ -569,12 +569,15 @@ "trait": "Qualification=Q08" } ], + "justification": "Covers the SDK's own cleanup (failed-enable detach, callback detach), which stays incomplete and retryable. It does not cover a throwing OnDisable: DisablePluginTests.OnDisable_throwing_is_logged_but_reports_TRUE_after_the_plugin_is_disabled shows the SDK logs the exception and reports TRUE, so that test is deliberately not Q08 evidence.", "date": "2026-09-23" }, "C3": { "status": "NotExecuted", "evidenceKind": "ToQualify", - "profileId": "ce-7.7.0.10621-x64-managed-hostfxr" + "profileId": "ce-7.7.0.10621-x64-managed-hostfxr", + "justification": "The C3 scenario injects a throwing OnDisable. The SDK reports TRUE after it (DisablePluginTests.OnDisable_throwing_is_logged_but_reports_TRUE_after_the_plugin_is_disabled), so Cheat Engine is expected to show the plugin as cleanly disabled: pre-declared Failed until the lifecycle decision on a throwing OnDisable changes.", + "expected": "Failed" } } }, From f02b20e02fa5dff1ee6bd1c435aae3f5198fa02a Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:25:36 +0200 Subject: [PATCH 041/199] Check the repository locators of the support profile measurements support-profile.json lists, for each measured hash, the repository lines that declare it (declaredIn, for example LiveProbeAuthorization.cs:16). Nothing checked those locators, so they would drift silently. A test now requires each cited line to hold the measured SHA-256 and, when it moved, prints where it is declared now. --- .../Qualification/SupportProfileTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs index 1e3d2599..318805ba 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/SupportProfileTests.cs @@ -107,6 +107,45 @@ public void Profile_host_hash_and_version_equal_the_LiveProbe_authorization_cons Assert.Equal("cheatengine-x86_64.exe", host.GetProperty("exeName").GetString()); } + [Fact] + public void Measurement_locators_name_repository_lines_that_declare_the_measured_hash() + { + List problems = []; + int locators = 0; + foreach (JsonElement measurement in SupportProfile.GetProperty("measurements").EnumerateArray()) + { + string sha256 = measurement.GetProperty("sha256").GetString()!; + foreach (JsonElement declared in measurement.GetProperty("declaredIn").EnumerateArray()) + { + locators++; + string locator = declared.GetString()!; + int colon = locator.LastIndexOf(':'); + string file = locator[..colon]; + int line = int.Parse(locator[(colon + 1)..], System.Globalization.CultureInfo.InvariantCulture); + if (!QualificationDocuments.Exists(file)) + { + problems.Add($"{locator}: {file} does not exist."); + continue; + } + + string[] lines = QualificationDocuments.ReadNormalizedText(file).Split('\n'); + if (line >= 1 && line <= lines.Length && + lines[line - 1].Contains(sha256, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + int actual = Array.FindIndex(lines, text => text.Contains(sha256, StringComparison.OrdinalIgnoreCase)); + problems.Add(actual < 0 + ? $"{locator}: {file} no longer declares {sha256}." + : $"{locator}: the hash is now declared at {file}:{actual + 1}; update declaredIn."); + } + } + + Assert.True(locators >= 4, "The measurement record lost its repository locators."); + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + [Fact] public void Profile_celua_hash_is_the_audit_reference() { From 6d8487b858bec73064c8ac43332361b92ce46ef1 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:29:44 +0200 Subject: [PATCH 042/199] Guard the whole live probe callback thunk against exceptions The WI-9 smoke run built LiveProbe from the package, which brings the packaged analyzers, and CESDK1004 reported that an exception can escape CallbackShutdownThunk: the LuaState wrapper was created before the guard try. The repository build does not load the SDK analyzers for this harness, so only the package-mode build saw it. The whole body is now one guard try, and the catch builds its own wrapper around the raw state pointer. The package-mode build of the harness has no warning left. --- tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs index 72868b02..fc1bf1f3 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeState.cs @@ -726,9 +726,10 @@ private static void RunLuaThreadProbe() [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static int CallbackShutdownThunk(nint statePointer) { - LuaState state = new(statePointer); + // The whole body is one guard try (CESDK1004): nothing may run before it, not even the state wrapper. try { + LuaState state = new(statePointer); if (!LuaThunk.TryGetState(state, out CallbackCounter? counter)) { return LuaThunk.Fail(state, "callback shutdown probe state is unavailable"u8); @@ -739,7 +740,7 @@ private static int CallbackShutdownThunk(nint statePointer) } catch (Exception exception) { - return LuaThunk.Fail(state, exception); + return LuaThunk.Fail(new LuaState(statePointer), exception); } } From 0ee3d65f34c32fc7b716fccb868100072882b804 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:54:10 +0200 Subject: [PATCH 043/199] Restore HKCU after a session when no other Cheat Engine runs Stage 12 read (Get-CheatEngineProcess).Count under Set-StrictMode Latest. The function output is enumerated, so zero processes arrive as $null and one as a single Process, and .Count throws on both. Whenever a session changed HKCU with fewer than two Cheat Engine processes running (the normal case, since the guided Checkpoint B scenarios tick and untick plugins in Plugins64), the catch turned the automatic restore into exit 7 and the run stopped before the receipt. The decision now lives in QualificationRunner.psm1 as the pure function Resolve-RegistryRestoreAction (unchanged key: None; changed key with no other instance: Restore; changed key next to another instance: Refuse, exit 7). The runner counts instances with @(Get-CheatEngineProcess), whose output type is declared as a single Process, which also clears the PSUseOutputTypeCorrectly finding. LocalQualificationRunnerTests covers each decision branch and executes the runner's own Get-CheatEngineProcess and stage 12 count expression in strict mode against zero, one and two fake processes; it also requires every Get-CheatEngineProcess call to be wrapped in @(). --- .../Invoke-LocalQualification.ps1 | 28 +++++--- eng/qualification/QualificationRunner.psm1 | 27 ++++++++ eng/qualification/README.md | 16 +++-- .../LocalQualificationRunnerTests.cs | 66 +++++++++++++++++++ 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 index bb5abcc4..082459eb 100644 --- a/eng/qualification/Invoke-LocalQualification.ps1 +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -155,11 +155,17 @@ function Get-PeMachine { } function Get-CheatEngineProcess { + <# + .SYNOPSIS + The running Cheat Engine processes. The output is enumerated like any function output: zero processes arrive as + $null and one as a single Process, and .Count on either throws under strict mode, so every caller wraps the + call in @() (LocalQualificationRunnerTests checks it). + #> [CmdletBinding()] - [OutputType([System.Diagnostics.Process[]])] + [OutputType([System.Diagnostics.Process])] param() - return @(Get-Process | Where-Object { Test-CheatEngineProcessName -Name $_.ProcessName }) + Get-Process | Where-Object { Test-CheatEngineProcessName -Name $_.ProcessName } } function Invoke-Preflight { @@ -861,19 +867,21 @@ try { Remove-Item Env:CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE -ErrorAction SilentlyContinue } - # Stage 12, HKCU after, compare, restore only a non-empty difference. Skipped when Cheat Engine never - # started (no export before). Any failure here is the critical exit 7 with the manual restore command, - # never the generic exit 6 of the script trap. + # Stage 12, HKCU after, compare, restore only a non-empty difference with no other Cheat Engine running + # (Resolve-RegistryRestoreAction). Skipped when Cheat Engine never started (no export before). Any failure + # here is the critical exit 7 with the manual restore command, never the generic exit 6 of the script trap. if ($registryCaptured) { $manualRestore = "Backup: $registryBefore; command: reg delete `"$RegistryKey`" /f; reg import `"$registryBefore`"" - $otherInstances = $false + $restoreAction = 'None' try { $existsAfter = Export-CheatEngineRegistry -Path $registryAfter if (-not $existsAfter) { [System.IO.File]::WriteAllText($registryAfter, '', [System.Text.Encoding]::Unicode) } $diff = Compare-RegistrySnapshot -Before (Read-RegistryExport -Path $registryBefore) -After (Read-RegistryExport -Path $registryAfter) -RootKey 'HKEY_CURRENT_USER\Software\Cheat Engine' $registry = [ordered]@{ key = $RegistryKey; exportBeforeSha256 = Get-QualificationFileSha256 -Path $registryBefore; exportAfterSha256 = Get-QualificationFileSha256 -Path $registryAfter; restored = $false; diff = $diff } - $otherInstances = ($diff.added + $diff.removed + $diff.changed) -gt 0 -and (Get-CheatEngineProcess).Count -gt 0 - if (-not $otherInstances -and ($diff.added + $diff.removed + $diff.changed) -gt 0) { + # @(): with zero or one Cheat Engine process the function output is not an array (see Get-CheatEngineProcess). + $otherInstanceCount = @(Get-CheatEngineProcess).Count + $restoreAction = Resolve-RegistryRestoreAction -Diff $diff -OtherInstanceCount $otherInstanceCount + if ($restoreAction -eq 'Restore') { $registry.restored = Restore-CheatEngineRegistry -Before $registryBefore -ExistedBefore $existedBefore -Verify (Join-Path $sessionDirectory 'hkcu-restored.reg') } } @@ -881,10 +889,10 @@ try { Exit-Qualification -Code 7 -Reason "HKCU could not be compared or restored after $($definition.id) ($($_.Exception.Message)). $manualRestore" } - if ($otherInstances) { + if ($restoreAction -eq 'Refuse') { Exit-Qualification -Code 7 -Reason "HKCU changed ($($diff.valueNames -join ', ')) while another Cheat Engine instance runs; restore it by hand after closing it. $manualRestore" } - if (($diff.added + $diff.removed + $diff.changed) -gt 0 -and -not $registry.restored) { Exit-Qualification -Code 7 -Reason "HKCU restore could not be verified. $manualRestore" } + if ($restoreAction -eq 'Restore' -and -not $registry.restored) { Exit-Qualification -Code 7 -Reason "HKCU restore could not be verified. $manualRestore" } } } diff --git a/eng/qualification/QualificationRunner.psm1 b/eng/qualification/QualificationRunner.psm1 index 815c134c..8a7a3d0e 100644 --- a/eng/qualification/QualificationRunner.psm1 +++ b/eng/qualification/QualificationRunner.psm1 @@ -597,6 +597,32 @@ function Test-CheatEngineProcessName { return $Name -match '^(?:cheatengine-(?:x86_64|i386)(?:-SSE4-AVX2)?|Cheat Engine)$' } +function Resolve-RegistryRestoreAction { + <# + .SYNOPSIS + Stage 12 decision on HKCU\Software\Cheat Engine after a session: None, Restore or Refuse. + .DESCRIPTION + None when the session left the key unchanged (nothing to restore, whoever else runs). Restore when it changed the + key and no other Cheat Engine instance runs. Refuse (exit 7, the operator restores by hand) when it changed the + key while another instance runs: every Cheat Engine copy shares the key, so a reg delete + reg import would also + erase that instance's own writes. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [System.Collections.IDictionary] $Diff, + [Parameter(Mandatory)] [ValidateRange('NonNegative')] [int] $OtherInstanceCount + ) + + foreach ($name in 'added', 'removed', 'changed') { + if (-not $Diff.Contains($name)) { throw "The registry difference has no '$name' count." } + } + + if (([long] $Diff['added'] + [long] $Diff['removed'] + [long] $Diff['changed']) -eq 0) { return 'None' } + if ($OtherInstanceCount -gt 0) { return 'Refuse' } + return 'Restore' +} + function Get-RestoredPackageContentHash { <# .SYNOPSIS @@ -1000,6 +1026,7 @@ Export-ModuleMember -Function @( 'Get-DepsJsonProblem' 'Test-QualificationBundleClosure' 'Test-CheatEngineProcessName' + 'Resolve-RegistryRestoreAction' 'Get-RestoredPackageContentHash' 'Test-QualificationWorkRoot' 'Get-CiEnvironmentVariable' diff --git a/eng/qualification/README.md b/eng/qualification/README.md index 2c79f2d6..dcddd2e1 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -16,7 +16,7 @@ operator's Cheat Engine settings changed. | File | Content | |-----------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| | `Invoke-LocalQualification.ps1` | The runner: guard, preflight, mutex, sandbox, bundles, targets, registry, Cheat Engine session, receipts. | -| `QualificationRunner.psm1` | Pure helpers the runner and the tests share: hashing with the LF rule, registry parsing and name-only diff, redaction, event log bounding, receipt id and assembly, Lua literals, bundle closure, restored content hash, pass-rule evaluation. | +| `QualificationRunner.psm1` | Pure helpers the runner and the tests share: hashing with the LF rule, registry parsing, name-only diff and restore decision, redaction, event log bounding, receipt id and assembly, Lua literals, bundle closure, restored content hash, pass-rule evaluation. | | `driver/zz_cesdk_qualification.template.lua` | The autorun Lua driver template: runs one scenario step per timer tick under `pcall`, appends one JSON event per line, resumes after a Lua state reset. | | `scenarios.json` | The Checkpoint B plan: harnesses, target, steps (Lua or Operator), observed values and a declarative pass rule per scenario. | @@ -59,9 +59,12 @@ operator's Cheat Engine settings changed. `resetLuaState()`) resumes at the pending operator step; the runner keeps the first answer and does not ask twice. 11. **Cleanup** (always): stops the target and stray sandbox processes, removes the driver, the manifest, the fault switch and the environment variables. A failing cleanup step is reported (exit 6) and never skips stage 12. -12. **HKCU after.** Exports, compares by value names, restores only a non-empty difference with no other Cheat Engine - running, and verifies the restore. Any failure of this stage, including a failing `reg export`, is exit 7 with the - manual command and the backup path, never the generic exit 6. +12. **HKCU after.** Exports, compares by value names, then `Resolve-RegistryRestoreAction` decides: an unchanged key is + left alone; a changed key is restored and the restore verified when no other Cheat Engine runs; a changed key next + to another Cheat Engine instance is refused (exit 7), because every copy shares the key and a restore would erase + that instance's writes. Guided scenarios that tick or untick plugins change the key (`Plugins64`), so the restore + is the normal path of a Checkpoint B run. Any failure of this stage, including a failing `reg export`, is exit 7 + with the manual command and the backup path, never the generic exit 6. 13. **Redaction and receipt.** Replaces the work root, sandbox, bundles, repository, installation, user and machine names with placeholders, bounds the event log, evaluates the pass rule and writes `.json` and `.events.json` (receipt id `R---`). @@ -131,6 +134,11 @@ authorization manifest contain private data and must never be committed. lock-file value (`Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash`); only Cheat Engine's own executables count as another instance, never a process such as a `CheatEngine.*` test host (`Only_Cheat_Engine_executables_count_as_another_instance`). +- HKCU is restored after a session only when the key changed and no other Cheat Engine runs, is refused with exit 7 + when another instance runs, and is left alone when unchanged + (`Registry_is_restored_only_after_a_change_and_never_next_to_another_Cheat_Engine_instance`); stage 12 counts the + running instances correctly in strict mode with none, one or several of them + (`Stage_12_counts_Cheat_Engine_instances_in_strict_mode_with_none_one_or_several_running`). - Every Checkpoint B scenario names a matrix cell and only Lua functions its harnesses declare (`Every_Checkpoint_B_scenario_exists_and_cites_harness_commands_that_exist`), and every generated driver compiles with Cheat Engine's Lua 5.3 module (`Driver_templates_are_valid_Lua`). diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs index 9f56f9c3..d1ee85a6 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -341,6 +341,7 @@ public void Bundle_closure_check_accepts_a_package_consumer_bundle_with_the_gene Directory.Delete(root, true); } } + [Fact] public void Work_root_is_refused_when_it_overlaps_Cheat_Engine_holds_non_ASCII_or_sees_the_workspace() { @@ -433,6 +434,71 @@ public void Only_Cheat_Engine_executables_count_as_another_instance() } } + [Theory] + [InlineData(1, 0, 0, 0, "Restore")] + [InlineData(0, 2, 1, 1, "Refuse")] + [InlineData(0, 0, 1, 3, "Refuse")] + [InlineData(0, 0, 0, 0, "None")] + [InlineData(0, 0, 0, 2, "None")] + public void Registry_is_restored_only_after_a_change_and_never_next_to_another_Cheat_Engine_instance(int added, + int removed, int changed, int otherInstances, string expected) + { + // Every Cheat Engine copy shares HKCU\Software\Cheat Engine: a restore next to another instance would erase its + // writes (Refuse = exit 7, restored by hand), and an unchanged key is never touched. + JsonElement action = RunJson(ModuleImport + $$""" + $diff = [ordered]@{ added = {{added}}; removed = {{removed}}; changed = {{changed}}; valueNames = @('Plugins64\0') } + ConvertTo-Json -Compress -InputObject (Resolve-RegistryRestoreAction -Diff $diff -OtherInstanceCount {{otherInstances}}) + """); + + Assert.Equal(expected, action.GetString()); + } + + [Fact] + public void Stage_12_counts_Cheat_Engine_instances_in_strict_mode_with_none_one_or_several_running() + { + // Executes the runner's own Get-CheatEngineProcess and its stage 12 count expression, under the runner's strict + // mode, against a fake process list. Fake processes are class instances, not PSCustomObject, so an unwrapped + // (Get-CheatEngineProcess).Count throws for zero and for one process exactly as with real Process objects. + JsonElement result = RunJson(ModuleImport + $$""" + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + class FakeProcess { [string] $ProcessName } + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile({{PowerShellProcess.Quote(QualificationDocuments.Absolute(RunnerScript))}}, [ref] $null, [ref] $errors) + $function = $ast.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Get-CheatEngineProcess' }, $true) + $assignments = @($ast.FindAll({ $args[0] -is [System.Management.Automation.Language.AssignmentStatementAst] -and $args[0].Left.Extent.Text -eq '$otherInstanceCount' }, $true)) + $queries = @($ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] -and $args[0].GetCommandName() -eq 'Get-CheatEngineProcess' }, $true)) + $unwrapped = @($queries | Where-Object { -not ($_.Parent -is [System.Management.Automation.Language.PipelineAst] -and $_.Parent.Parent -is [System.Management.Automation.Language.StatementBlockAst] -and $_.Parent.Parent.Parent -is [System.Management.Automation.Language.ArrayExpressionAst]) } | ForEach-Object { "line $($_.Extent.StartLineNumber)" }) + $decisions = @($ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] -and $args[0].GetCommandName() -eq 'Resolve-RegistryRestoreAction' }, $true) | ForEach-Object { $_.Extent.Text }) + . ([scriptblock]::Create($function.Extent.Text)) + $count = [scriptblock]::Create($assignments[0].Right.Extent.Text) + $counts = [ordered]@{} + foreach ($case in @( + [ordered]@{ name = 'none'; processes = @() } + [ordered]@{ name = 'one'; processes = @('cheatengine-x86_64') } + [ordered]@{ name = 'two'; processes = @('cheatengine-x86_64-SSE4-AVX2', 'Cheat Engine') } + [ordered]@{ name = 'onlyOthers'; processes = @('CheatEngine.SDK.QualificationTarget', 'pwsh') })) { + $script:fakeNames = @($case.processes) + function Get-Process { foreach ($name in $script:fakeNames) { [FakeProcess] @{ ProcessName = $name } } } + $counts[$case.name] = & $count + } + [ordered]@{ parseErrors = @($errors).Count; assignments = $assignments.Count; queries = $queries.Count; unwrapped = $unwrapped; decisions = $decisions; counts = $counts } | ConvertTo-Json -Compress -Depth 4 + """); + + Assert.Equal(0, result.GetProperty("parseErrors").GetInt32()); + Assert.Equal(1, result.GetProperty("assignments").GetInt32()); + Assert.True(result.GetProperty("queries").GetInt32() >= 2, "The preflight and stage 12 both query Cheat Engine processes."); + Assert.True(result.GetProperty("unwrapped").GetArrayLength() == 0, + "Wrap every Get-CheatEngineProcess call in @(): " + result.GetProperty("unwrapped").GetRawText()); + string decision = Assert.Single(result.GetProperty("decisions").EnumerateArray()).GetString()!; + Assert.Contains("-OtherInstanceCount $otherInstanceCount", decision, StringComparison.Ordinal); + JsonElement counts = result.GetProperty("counts"); + Assert.Equal(0, counts.GetProperty("none").GetInt32()); + Assert.Equal(1, counts.GetProperty("one").GetInt32()); + Assert.Equal(2, counts.GetProperty("two").GetInt32()); + Assert.Equal(0, counts.GetProperty("onlyOthers").GetInt32()); + } + [Fact] public void Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash() { From e8f523eed5ea60e728f60fc5aecf26eb276a1b1e Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:54:26 +0200 Subject: [PATCH 044/199] Refuse a malformed -HeadSha even without -PullRequest The argument check was (PullRequest > 0) -xor (HeadSha matches 40 hex), so -PullRequest 0 with a malformed -HeadSha passed and the value was silently ignored, and the match was case-insensitive although git object ids in receipts are lowercase. A non-empty -HeadSha must now be 40 lowercase hexadecimal characters, and -PullRequest and -HeadSha must be given together. --- eng/qualification/Invoke-LocalQualification.ps1 | 3 ++- eng/qualification/README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/eng/qualification/Invoke-LocalQualification.ps1 b/eng/qualification/Invoke-LocalQualification.ps1 index 082459eb..c0335438 100644 --- a/eng/qualification/Invoke-LocalQualification.ps1 +++ b/eng/qualification/Invoke-LocalQualification.ps1 @@ -726,7 +726,8 @@ if (-not $PreflightOnly) { if (-not $Smoke) { if ($PackageSource -eq 'CiArtifact' -and $CiRunUrl -notmatch '^https://github\.com/CheatEngineNet/CheatEngine\.SDK/actions/runs/\d+(/attempts/\d+)?$') { Exit-Qualification -Code 2 -Reason '-CiRunUrl must name the CI run that produced the artifact.' } if ($Operator -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$') { Exit-Qualification -Code 2 -Reason '-Operator must be the GitHub handle recorded in the receipts.' } - if (($PullRequest -gt 0) -xor ($HeadSha -match '^[0-9a-f]{40}$')) { Exit-Qualification -Code 2 -Reason '-PullRequest and -HeadSha go together.' } + if ($HeadSha -and $HeadSha -cnotmatch '^[0-9a-f]{40}$') { Exit-Qualification -Code 2 -Reason '-HeadSha must be the 40-character lowercase hexadecimal head commit of the pull request.' } + if (($PullRequest -gt 0) -xor [bool] $HeadSha) { Exit-Qualification -Code 2 -Reason '-PullRequest and -HeadSha go together.' } } } diff --git a/eng/qualification/README.md b/eng/qualification/README.md index dcddd2e1..fce0d9fe 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -79,7 +79,7 @@ operator's Cheat Engine settings changed. | `-PackagePath ` | The exact `CheatEngine.SDK` `.nupkg`. Required unless `-PreflightOnly`. | | `-PackageSource CiArtifact\|NuGetOrg` | Where the package came from (default `CiArtifact`). | | `-CiRunUrl ` | The CI run that produced the artifact; required for `CiArtifact` receipts. | -| `-PullRequest `, `-HeadSha ` | Pull request identity recorded in receipts (both or neither). | +| `-PullRequest `, `-HeadSha ` | Pull request identity recorded in receipts (both or neither; the SHA is 40 lowercase hexadecimal characters). | | `-Operator ` | GitHub handle recorded in receipts; required for receipts. | | `-CheatEnginePath ` | The installation to copy; default `%ProgramFiles%\Cheat Engine`. Read and copied only. | | `-WorkRoot ` | Default `%LOCALAPPDATA%\CheatEngineNet\qualification`; refused when it holds a non-ASCII character (the driver writes through Cheat Engine's ANSI file API), overlaps `-CheatEnginePath`, lies inside a git work tree or below the repository's parent directory. | From 27b041a0688979fa5b40e4ce218880f9adcef0ae Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:59:21 +0200 Subject: [PATCH 045/199] Require the index loadPlugin returns in the load pass-rule checks loadPlugin returns nil on failure without raising a Lua error (celua.txt:643), so the driver's protected call succeeds and a {step: load, ok: true} check passed even when the plugin did not load. Every scenario whose load must succeed now checks that the first returned value is a number of 0 or greater (Q06 injects an OnEnable fault, so its load result stays an observation). The atLeast and greaterThan comparisons accept JSON numbers only, so a Lua error string or a numeric string fails the check instead of being coerced or making the [double] conversion throw. A_load_step_passes_only_with_the_non_negative_index_loadPlugin_returns_on_success covers nil, no value, negative, string and Lua-error results, and the Checkpoint B plan test requires the check on every loadPlugin step outside a create or enable fault. --- docs/qualification/local-protocol.md | 3 +- eng/qualification/QualificationRunner.psm1 | 18 +++- eng/qualification/README.md | 3 + eng/qualification/scenarios.json | 21 +++-- .../LocalQualificationRunnerTests.cs | 83 ++++++++++++++++++- 5 files changed, 118 insertions(+), 10 deletions(-) diff --git a/docs/qualification/local-protocol.md b/docs/qualification/local-protocol.md index 48b53713..2d7aba51 100644 --- a/docs/qualification/local-protocol.md +++ b/docs/qualification/local-protocol.md @@ -77,7 +77,8 @@ continues. A plumbing check with a local pack uses `-Smoke`: it writes `smoke-re The plan is [`eng/qualification/scenarios.json`](../../eng/qualification/scenarios.json); preconditions, operation and expected result come from the [matrix](matrix.json). Every LiveProbe scenario opens the x64 qualification target first and loads the plugin with `loadPlugin`, so the probe's authorization gate (exact host, short-lived manifest, target -opened in Cheat Engine) is satisfied. +opened in Cheat Engine) is satisfied. `loadPlugin` returns nil on failure without raising a Lua error, so every pass +rule except Q06's (where the enable fails on purpose) also requires each load to return an index of 0 or greater. | Scenario | Level | Harness | Target | Steps | Pass rule | |----------|-------|-------------------------------|--------|--------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------| diff --git a/eng/qualification/QualificationRunner.psm1 b/eng/qualification/QualificationRunner.psm1 index 8a7a3d0e..e1e2c775 100644 --- a/eng/qualification/QualificationRunner.psm1 +++ b/eng/qualification/QualificationRunner.psm1 @@ -876,7 +876,9 @@ function Test-StepCheck { $value = Get-StepValue -Step $Step -Selector ([string] $Check.json) if ($names -contains 'equals') { return ($null -ne $value) -and ($value -ceq $Check.equals) } if ($names -contains 'present') { return $null -ne $value } - if ($names -contains 'atLeast') { return ($null -ne $value) -and ([double] $value -ge [double] $Check.atLeast) } + # Numeric comparisons accept JSON numbers only: a Lua error string, a numeric string or a boolean fails the check + # instead of being coerced (or throwing) in the [double] conversion. + if ($names -contains 'atLeast') { return (Test-JsonNumber -Value $value) -and ([double] $value -ge [double] $Check.atLeast) } if ($names -contains 'contains') { return @($value) -ccontains $Check.contains } if ($names -contains 'startsWith') { return ($null -ne $value) -and ([string] $value).StartsWith([string] $Check.startsWith, [System.StringComparison]::Ordinal) } if ($names -contains 'equalsPackagedBridge') { @@ -886,13 +888,25 @@ function Test-StepCheck { $reference = if ($names -contains 'greaterThan') { $Check.greaterThan } else { $Check.sameAs } $other = Get-StepValue -Step $Steps[$reference.step] -Selector ([string] $reference.json) if ($null -eq $value -or $null -eq $other) { return $false } - if ($names -contains 'greaterThan') { return [double] $value -gt [double] $other } + if ($names -contains 'greaterThan') { return (Test-JsonNumber -Value $value) -and (Test-JsonNumber -Value $other) -and ([double] $value -gt [double] $other) } return $value -ceq $other } throw "Unknown pass-rule check: $(ConvertTo-Json -InputObject $Check -Compress)." } +function Test-JsonNumber { + <# + .SYNOPSIS + True for a number as ConvertFrom-Json returns it; false for $null, strings (numeric or not) and booleans. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)] [AllowNull()] [object] $Value) + + return $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal] -or $Value -is [System.Numerics.BigInteger] +} + function Format-StepCheck { <# .SYNOPSIS diff --git a/eng/qualification/README.md b/eng/qualification/README.md index fce0d9fe..e809460e 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -130,6 +130,9 @@ authorization manifest contain private data and must never be committed. root is refused (`Work_root_is_refused_when_it_overlaps_Cheat_Engine_holds_non_ASCII_or_sees_the_workspace`); a coexistence receipt passes only when plugin A was observably removed and Q07 only when the plugin is still enabled after the pump (`Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_still_enabled_after_the_refused_disable`); + a load step passes only when `loadPlugin` returned a number of 0 or greater, never on nil, a string or a Lua error + (`A_load_step_passes_only_with_the_non_negative_index_loadPlugin_returns_on_success`), and every load that must + succeed carries that check (`Every_Checkpoint_B_scenario_exists_and_cites_harness_commands_that_exist`); the recorded content hash is the lock-file value (`Content_hash_is_the_lock_file_value_the_restore_recorded_not_the_file_bytes_hash`); only Cheat Engine's own executables count as another instance, never a process such as a `CheatEngine.*` test host diff --git a/eng/qualification/scenarios.json b/eng/qualification/scenarios.json index c94c6cb8..ee02b8bb 100644 --- a/eng/qualification/scenarios.json +++ b/eng/qualification/scenarios.json @@ -20,7 +20,7 @@ "kind": "OperatorJudgement", "passKind": "Functional", "checks": [ - { "step": "load", "ok": true }, + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "status", "json": "bootstrap.tailCanaryWritten", "equals": true }, { "step": "review", "answer": "y" } ] @@ -43,6 +43,7 @@ "kind": "NotApplicableObservation", "justification": "The exact host hands a complete managed exports record (the observed size is recorded); a reduced or missing record cannot be produced on Cheat Engine 7.7 without modifying it. The C1 refusal tests are the evidence of the refusal.", "checks": [ + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "status", "json": "context.reportedExportsSize", "present": true } ] } @@ -64,7 +65,7 @@ "kind": "Automated", "passKind": "Functional", "checks": [ - { "step": "load", "ok": true }, + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "status", "ok": true }, { "step": "status", "json": "bootstrap.calls", "atLeast": 1 }, { "step": "status", "json": "bootstrap.opaqueSecondInt", "present": true }, @@ -92,6 +93,7 @@ "kind": "Automated", "passKind": "Functional", "checks": [ + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "first", "ok": true }, { "step": "second", "ok": true }, { "step": "second", "json": "context.epoch", "greaterThan": { "step": "first", "json": "context.epoch" } }, @@ -119,7 +121,7 @@ "kind": "OperatorJudgement", "passKind": "Functional", "checks": [ - { "step": "load", "ok": true }, + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "status", "ok": true }, { "step": "intact", "answer": "y" } ] @@ -173,6 +175,7 @@ "passKind": "RefusalVerified", "requiresAnswer": { "step": "acted", "answer": "y" }, "checks": [ + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "pump", "ok": true }, { "step": "pump", "json": "enabledAfter", "equals": true }, { "step": "pump", "json": "phaseAfter", "equals": "Enabled" }, @@ -203,6 +206,7 @@ "kind": "OperatorJudgement", "passKind": "Functional", "checks": [ + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "before", "ok": true }, { "step": "status", "ok": true }, { "step": "status", "json": "faultInjection.injected", "contains": "OnDisable@1" }, @@ -228,7 +232,7 @@ "kind": "Automated", "passKind": "Functional", "checks": [ - { "step": "load", "ok": true }, + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "throw", "ok": false }, { "step": "throw", "errorContains": "deliberate managed exception" }, { "step": "status", "ok": true }, @@ -256,6 +260,7 @@ "kind": "Automated", "passKind": "Functional", "checks": [ + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "live", "ok": true }, { "step": "kept", "ok": false }, { "step": "kept", "errorContains": "released" } @@ -288,6 +293,7 @@ "kind": "OperatorJudgement", "passKind": "Functional", "checks": [ + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "before", "ok": true }, { "step": "judge", "answer": "y" } ] @@ -316,6 +322,7 @@ "kind": "OperatorJudgement", "passKind": "Functional", "checks": [ + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "threads", "ok": true }, { "step": "judge", "answer": "y" } ] @@ -345,7 +352,7 @@ "kind": "Automated", "passKind": "Functional", "checks": [ - { "step": "load", "ok": true }, + { "step": "load", "json": "0", "atLeast": 0 }, { "step": "status", "json": "identity.pluginAssemblyLocation", "startsWith": "" }, { "step": "status", "json": "identity.hostingAssemblyLocation", "startsWith": "" }, { "step": "profile", "json": "bridge.path", "startsWith": "" }, @@ -378,6 +385,8 @@ "passKind": "Functional", "requiresAnswer": { "step": "removeA", "answer": "y" }, "checks": [ + { "step": "loadA", "json": "0", "atLeast": 0 }, + { "step": "loadB", "json": "0", "atLeast": 0 }, { "step": "identityA", "ok": true }, { "step": "identityB", "ok": true }, { "step": "pingB", "ok": true }, @@ -412,6 +421,8 @@ "passKind": "Functional", "requiresAnswer": { "step": "removeA", "answer": "y" }, "checks": [ + { "step": "loadA", "json": "0", "atLeast": 0 }, + { "step": "loadB", "json": "0", "atLeast": 0 }, { "step": "identityA", "ok": true }, { "step": "identityB", "ok": true }, { "step": "pingB", "ok": true }, diff --git a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs index d1ee85a6..748bdce8 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Qualification/LocalQualificationRunnerTests.cs @@ -381,6 +381,7 @@ public void Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_sti $values = if ($Json) { @([pscustomobject]@{ type = 'string'; value = $Json }) } else { @() } [pscustomobject]@{ id = $Id; ok = $Ok; values = $values } } + function New-LoadStep([string] $Id) { [pscustomobject]@{ id = $Id; ok = $true; values = @([pscustomobject]@{ type = 'integer'; value = 0 }) } } function Get-Status($Scenario, $Steps, $Answers) { $outcome = Resolve-QualificationOutcome -Scenario $Scenario -Steps $Steps -Answers $Answers "$($outcome.status) $($outcome.passKind)".Trim() @@ -388,7 +389,7 @@ public void Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_sti $result = [ordered]@{} foreach ($id in 'Q09.a', 'Q09.b') { $scenario = @($plan.scenarios | Where-Object id -eq $id)[0] - $steps = [ordered]@{} + $steps = [ordered]@{ loadA = New-LoadStep 'loadA'; loadB = New-LoadStep 'loadB' } foreach ($name in 'identityA', 'identityB', 'pingB', 'pingA2', 'pingB2') { $steps[$name] = New-Step $name $true } $steps.pingA = New-Step 'pingA' $false $result["$id removed"] = Get-Status $scenario $steps ([ordered]@{ removeA = 'y' }) @@ -397,7 +398,7 @@ public void Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_sti $result["$id notRemoved"] = Get-Status $scenario $steps ([ordered]@{ removeA = 'n' }) } $q07 = @($plan.scenarios | Where-Object id -eq 'Q07')[0] - $steps = [ordered]@{ pump = New-Step 'pump' $true '{"enabledAfter":true,"phaseAfter":"Enabled"}'; status = New-Step 'status' $true '{"context":{"phase":"Enabled"} }' } + $steps = [ordered]@{ load = New-LoadStep 'load'; pump = New-Step 'pump' $true '{"enabledAfter":true,"phaseAfter":"Enabled"}'; status = New-Step 'status' $true '{"context":{"phase":"Enabled"} }' } $result['Q07 refused'] = Get-Status $q07 $steps ([ordered]@{ acted = 'y' }) $steps.status = New-Step 'status' $true '{"context":{"phase":"Disabled"} }' $result['Q07 disabledAfterThePump'] = Get-Status $q07 $steps ([ordered]@{ acted = 'y' }) @@ -415,6 +416,46 @@ public void Pass_rules_require_the_observed_removal_of_plugin_A_and_a_plugin_sti Assert.Equal("Failed", result.GetProperty("Q07 disabledAfterThePump").GetString()); } + [Fact] + public void A_load_step_passes_only_with_the_non_negative_index_loadPlugin_returns_on_success() + { + // loadPlugin returns nil on failure without raising (celua.txt:643), so a protected call that succeeded proves + // nothing; the value must be a number of 0 or greater. The step records are parsed from driver JSON as in a run. + JsonElement result = RunJson(ModuleImport + $$""" + $plan = Get-Content -LiteralPath {{PowerShellProcess.Quote(QualificationDocuments.Absolute(Scenarios))}} -Raw | ConvertFrom-Json -Depth 64 + $scenario = @($plan.scenarios | Where-Object id -eq 'Q04')[0] + $status = [ordered]@{ schema = 'ce77-live-probe-status-v1'; bootstrap = [ordered]@{ calls = 1; opaqueSecondInt = 0; interpretation = 'none' } } | ConvertTo-Json -Compress + $statusLine = [pscustomobject]@{ tMs = 2; source = 'Driver'; kind = 'StepResult'; message = (@{ step = 3; id = 'status'; action = 'call'; ok = $true; values = @(@{ type = 'string'; value = $status }) } | ConvertTo-Json -Compress -Depth 8) } + $loads = [ordered]@{ + index0 = '{"step":2,"id":"load","action":"loadPlugin","ok":true,"values":[{"type":"integer","value":0}]}' + index3 = '{"step":2,"id":"load","action":"loadPlugin","ok":true,"values":[{"type":"integer","value":3}]}' + nil = '{"step":2,"id":"load","action":"loadPlugin","ok":true,"values":[{"type":"nil"}]}' + noValue = '{"step":2,"id":"load","action":"loadPlugin","ok":true,"values":[]}' + negative = '{"step":2,"id":"load","action":"loadPlugin","ok":true,"values":[{"type":"integer","value":-1}]}' + numericString = '{"step":2,"id":"load","action":"loadPlugin","ok":true,"values":[{"type":"string","value":"0"}]}' + luaError = '{"step":2,"id":"load","action":"loadPlugin","ok":false,"values":[{"type":"string","value":"attempt to call a nil value (global loadPlugin)"}]}' + notRecorded = $null + } + $result = [ordered]@{} + foreach ($name in $loads.Keys) { + $lines = @($statusLine) + if ($null -ne $loads[$name]) { $lines = @([pscustomobject]@{ tMs = 1; source = 'Driver'; kind = 'StepResult'; message = $loads[$name] }) + $lines } + $redacted = ConvertTo-RedactedSessionEvent -Raw $lines -Map (Get-RedactionMap -Paths ([ordered]@{ '' = 'C:\lab\work' })) -OffsetMs 0 + $outcome = Resolve-QualificationOutcome -Scenario $scenario -Steps $redacted.steps -Answers ([ordered]@{}) + $result[$name] = "$($outcome.status) $($outcome.passKind)".Trim() + } + $result | ConvertTo-Json -Compress + """); + + Assert.Equal("Passed Functional", result.GetProperty("index0").GetString()); + Assert.Equal("Passed Functional", result.GetProperty("index3").GetString()); + foreach (string failure in (string[]) ["nil", "noValue", "negative", "numericString", "luaError", "notRecorded"]) + { + Assert.True(string.Equals("Failed", result.GetProperty(failure).GetString(), StringComparison.Ordinal), + failure + ": " + result.GetProperty(failure).GetString()); + } + } + [Fact] public void Only_Cheat_Engine_executables_count_as_another_instance() { @@ -662,6 +703,44 @@ private static IEnumerable CheckSteps(string id, JsonElement scenario, H yield return $"{id}: a pass-rule check names the unknown step '{check.GetProperty("step").GetString()}'."; } } + + foreach (string problem in CheckLoadIndexes(id, scenario)) + { + yield return problem; + } + } + + // loadPlugin returns nil on failure without raising, so a load that must succeed is checked for its index. A fault + // injected while the plugin is created or enabled makes the load result the observation instead. + private static IEnumerable CheckLoadIndexes(string id, JsonElement scenario) + { + string fault = scenario.TryGetProperty("faultStage", out JsonElement stage) ? stage.GetString()! : "None"; + if (fault is "FactoryCreate" or "OnEnable") + { + yield break; + } + + HashSet indexChecked = new(StringComparer.Ordinal); + foreach (JsonElement check in scenario.GetProperty("passRule").GetProperty("checks").EnumerateArray()) + { + if (check.TryGetProperty("json", out JsonElement selector) && + string.Equals(selector.GetString(), "0", StringComparison.Ordinal) && + check.TryGetProperty("atLeast", out JsonElement atLeast) && atLeast.ValueKind == JsonValueKind.Number && + atLeast.GetInt32() == 0) + { + indexChecked.Add(check.GetProperty("step").GetString()!); + } + } + + foreach (JsonElement step in scenario.GetProperty("steps").EnumerateArray()) + { + string stepId = step.GetProperty("id").GetString()!; + if (step.TryGetProperty("action", out JsonElement action) && + string.Equals(action.GetString(), "loadPlugin", StringComparison.Ordinal) && !indexChecked.Contains(stepId)) + { + yield return $"{id}: the loadPlugin step '{stepId}' needs the pass-rule check {{ \"step\": \"{stepId}\", \"json\": \"0\", \"atLeast\": 0 }}."; + } + } } private static HashSet HarnessLuaFunctions() From 21ca24d95aed8b75cb187da6f5ff0409754fded8 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 06:00:58 +0200 Subject: [PATCH 046/199] Destroy the driver timer once and keep one event time base on resume The driver disabled its timer before closeCE() but never destroyed it, while spike-c3 D5 releases a timer by disabling it and destroying it exactly once. finish() now disables and destroys the timer once and drops the reference; it runs inside the timer's own OnTimer, where a destroy was observed safe (spike-c3 P6, T3). OnTimer is not cleared from inside its own callback, which was not observed. A driver that autorun loads again after a Lua state reset restarted its event clock at a new T0, so the runner's time-ordered event log could interleave events from before and after the reset. The progress file now also holds the first driver's T0 (getTickCount is process-wide), and a resumed driver keeps it. A dry run of a generated driver against mocked Cheat Engine globals with the repository's lua53-64.dll showed one destroy, closeCE() called and monotonic event times across a simulated reset. --- eng/qualification/README.md | 2 +- .../zz_cesdk_qualification.template.lua | 27 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/eng/qualification/README.md b/eng/qualification/README.md index e809460e..6b2e8e7d 100644 --- a/eng/qualification/README.md +++ b/eng/qualification/README.md @@ -17,7 +17,7 @@ operator's Cheat Engine settings changed. |-----------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| | `Invoke-LocalQualification.ps1` | The runner: guard, preflight, mutex, sandbox, bundles, targets, registry, Cheat Engine session, receipts. | | `QualificationRunner.psm1` | Pure helpers the runner and the tests share: hashing with the LF rule, registry parsing, name-only diff and restore decision, redaction, event log bounding, receipt id and assembly, Lua literals, bundle closure, restored content hash, pass-rule evaluation. | -| `driver/zz_cesdk_qualification.template.lua` | The autorun Lua driver template: runs one scenario step per timer tick under `pcall`, appends one JSON event per line, resumes after a Lua state reset. | +| `driver/zz_cesdk_qualification.template.lua` | The autorun Lua driver template: runs one scenario step per timer tick under `pcall`, appends one JSON event per line, resumes after a Lua state reset on the first driver's time base, and disables and destroys its timer once before `closeCE()`. | | `scenarios.json` | The Checkpoint B plan: harnesses, target, steps (Lua or Operator), observed values and a declarative pass rule per scenario. | ### Stages diff --git a/eng/qualification/driver/zz_cesdk_qualification.template.lua b/eng/qualification/driver/zz_cesdk_qualification.template.lua index eb536421..ef0c455b 100644 --- a/eng/qualification/driver/zz_cesdk_qualification.template.lua +++ b/eng/qualification/driver/zz_cesdk_qualification.template.lua @@ -97,18 +97,21 @@ local function emit(source, kind, message) return true end +-- The progress file holds " ". A driver that autorun loads again (after a Lua state +-- reset) resumes after the completed step and keeps the first driver's T0, so every event of the session shares one +-- time base (getTickCount is process-wide, not per Lua state) and the runner's time-ordered log does not interleave. local function readProgress() local text = readAll(RUN.progress) - if text == nil then return 0 end - local runId, index = text:match('^(%S+)%s+(%d+)') - if runId ~= RUN.id then return 0 end - return tonumber(index) + if text == nil then return 0, nil end + local runId, index, t0 = text:match('^(%S+)%s+(%d+)%s*(%-?%d*)') + if runId ~= RUN.id then return 0, nil end + return tonumber(index), tonumber(t0) end local function writeProgress(index) local f = io.open(RUN.progress, 'wb') if f == nil then return end - f:write(RUN.id, ' ', tostring(index)) + f:write(RUN.id, ' ', tostring(index), ' ', string.format('%d', T0)) f:close() end @@ -161,8 +164,14 @@ local function finish(reason) emit('Driver', 'Completed', reason) local f = io.open(RUN.done, 'wb') if f ~= nil then f:write(RUN.id) f:close() end - if state.timer ~= nil then - pcall(function() state.timer.Enabled = false end) + -- Release the timer exactly once (spike-c3 D5): disable, then destroy, and forget the reference. finish runs inside + -- the timer's own OnTimer, where a destroy was observed safe (spike-c3 P6, T3); OnTimer is not cleared from inside + -- its own callback, which was not observed. + local timer = state.timer + state.timer = nil + if timer ~= nil then + pcall(function() timer.Enabled = false end) + pcall(function() timer.destroy() end) end pcall(closeCE) end @@ -221,7 +230,9 @@ end local function start() if fileExists(RUN.done) then return end - state.index = readProgress() + local index, t0 = readProgress() + state.index = index + if index > 0 and math.type(t0) == 'integer' then T0 = t0 end emit('Driver', state.index == 0 and 'Started' or 'Resumed', encode({ runId = RUN.id, step = state.index, inMainThread = (type(inMainThread) == 'function') and inMainThread() or nil })) local timer = createTimer(nil, false) From ad2bf058d0e04e424031aa3e0a56861e3fb5786c Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 06:03:22 +0200 Subject: [PATCH 047/199] Record the loaded bridge module in the live probe host profile The ce77-live-host-profile-v1 record named the bridge as Path.Combine(AppContext.BaseDirectory, "cheatengine-sdk-lua-bridge.dll"). Cheat Engine starts the runtime once through hostfxr with its own ce.runtimeconfig.json, so AppContext.BaseDirectory is the Cheat Engine (sandbox) folder, while the SDK loads the bridge through LibraryImport and the plugin's dependency resolution from the plugin folder. Q40 checks that bridge.path starts with and that its SHA-256 equals the packaged bridge, so it would have failed for the wrong reason. The record now names the module the process actually loaded, found the same way as lua53-64.dll. HostProfileObservationTests checks that a bridge path in the record is a module loaded in the process, never the unloaded copy next to the test host. --- .../HostProfileObservationTests.cs | 39 +++++++++++++++++++ .../CheatEngine.SDK.LiveProbe.Tests/README.md | 6 ++- .../HostProfileObservation.cs | 4 +- tests/CheatEngine.SDK.LiveProbe/README.md | 6 ++- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/HostProfileObservationTests.cs b/tests/CheatEngine.SDK.LiveProbe.Tests/HostProfileObservationTests.cs index 08c2a47e..a11016da 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/HostProfileObservationTests.cs +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/HostProfileObservationTests.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using System.Text.Json; + using LiveProbe; namespace CheatEngine.SDK.LiveProbe.Tests; @@ -72,4 +75,40 @@ public void ObserveFileVersion_when_access_is_revoked_after_open_reports_typed_u Assert.Equal("unavailable: UnauthorizedAccessException", outcome); } + + [Fact] + public void Host_profile_records_the_loaded_bridge_module_and_never_a_file_next_to_the_application() + { + // The test output folder holds a cheatengine-sdk-lua-bridge.dll that this process does not need to load. The + // record may name a bridge only when the process loaded it; modules stay loaded, so a module listed after the + // capture was loaded when the record named it. + string record = HostProfileObservation.Capture(AuthorizationDecision.Denied("test")); + using JsonDocument document = JsonDocument.Parse(record); + JsonElement bridge = document.RootElement.GetProperty("bridge"); + string[] loaded = LoadedModulePaths("cheatengine-sdk-lua-bridge.dll"); + + if (bridge.TryGetProperty("path", out JsonElement path)) + { + Assert.Contains(path.GetString()!, loaded, StringComparer.OrdinalIgnoreCase); + } + else + { + Assert.Equal("not-observed", bridge.GetProperty("outcome").GetString()); + } + } + + private static string[] LoadedModulePaths(string fileName) + { + using Process process = Process.GetCurrentProcess(); + List paths = []; + foreach (ProcessModule module in process.Modules) + { + if (string.Equals(Path.GetFileName(module.FileName), fileName, StringComparison.OrdinalIgnoreCase)) + { + paths.Add(module.FileName); + } + } + + return [.. paths]; + } } diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/README.md b/tests/CheatEngine.SDK.LiveProbe.Tests/README.md index a9d8a384..c8e03c22 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/README.md +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/README.md @@ -28,7 +28,7 @@ because the harness keeps process-wide static state. | `LiveProbeStateTests` | Fresh authorization and target-PID checks before host-profile capture and every protected command. | | `LiveProbeStatusTests` | Text and JSON status (plugin id, epoch, exports size, raw second bootstrap integer), the exception hook and the pump hook. | | `LiveProbeFaultInjectionTests` | The `liveprobe.fault.json` switch: never read without authorization, exact stage selection, ignored and reported failures. | -| `HostProfileObservationTests` | Typed outcomes for missing, locked, vanishing or protected identity files. | +| `HostProfileObservationTests` | Typed outcomes for missing, locked, vanishing or protected identity files; the bridge record names the loaded module only. | ## Promise @@ -41,7 +41,9 @@ because the harness keeps process-wide static state. any host call (`LiveProbeStatusTests`). - The fault switch is ignored without authorization, selects exactly the requested stage, and treats an absent file as no fault (`LiveProbeFaultInjectionTests`). -- Identity-file failures are reported as typed outcomes (`HostProfileObservationTests`). +- Identity-file failures are reported as typed outcomes (`HostProfileObservationTests`), and the bridge identity is the + module the process loaded, never a file next to the application + (`HostProfileObservationTests.Host_profile_records_the_loaded_bridge_module_and_never_a_file_next_to_the_application`). ## Run the tests diff --git a/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs b/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs index 2b33a527..d3fe445d 100644 --- a/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs +++ b/tests/CheatEngine.SDK.LiveProbe/HostProfileObservation.cs @@ -36,7 +36,9 @@ internal static string Capture(AuthorizationDecision authorization) WriteFileIdentity(writer, "host", authorization.HostPath, authorization.HostSha256); WriteFileIdentity(writer, "lua", FindLoadedModulePath(LuaModule.CheatEngine64ModuleName), null); - WriteFileIdentity(writer, "bridge", Path.Combine(AppContext.BaseDirectory, BridgeFileName), null); + // The bridge module the process actually loaded (through the plugin's dependency resolution), never a file + // guessed next to AppContext.BaseDirectory: under Cheat Engine's hostfxr runtime that is not the plugin folder. + WriteFileIdentity(writer, "bridge", FindLoadedModulePath(BridgeFileName), null); WriteFileIdentity(writer, "plugin", typeof(HostProfileObservation).Assembly.Location, null); writer.WriteStartObject("target"); diff --git a/tests/CheatEngine.SDK.LiveProbe/README.md b/tests/CheatEngine.SDK.LiveProbe/README.md index 3fadf1a4..a67c030d 100644 --- a/tests/CheatEngine.SDK.LiveProbe/README.md +++ b/tests/CheatEngine.SDK.LiveProbe/README.md @@ -68,7 +68,7 @@ authorization manifest, the target image and CE's opened PID immediately before |-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------| | `ce77_live_probe_status()` | Human-readable: bootstrap calls, the raw second integer (`opaqueSecondInt`, never labelled size or version), `PluginHost.LastInitRecordArgument`, phase, plugin id, epoch, reported exports size, gates, prior observations. | operator | | `ce77_live_probe_status_json()` | The same facts as one `ce77-live-probe-status-v1` JSON object, plus assembly locations, MVIDs, the Hosting load context and every fault-switch decision. Not gated: it reads process-local facts only. | Q03, Q04, Q05, Q06, Q08, Q40 | -| `ce77_live_probe_host_profile()` | One `ce77-live-host-profile-v1` JSON identity record for the authorized CE host, loaded Lua module, adjacent bridge, plugin and disposable target. | Q40 | +| `ce77_live_probe_host_profile()` | One `ce77-live-host-profile-v1` JSON identity record for the authorized CE host, loaded Lua module, loaded bridge, plugin and disposable target. | Q40 | | `ce77_live_probe_throw_managed_exception()` | Throws `InvalidOperationException("CE 7.7 live probe deliberate managed exception (Q14).")` inside the generated thunk; call it under `pcall` and record the Lua error. | Q14 | | `ce77_live_probe_pump_messages(seconds)` | Pumps CE's messages for 1–60 seconds from admitted main-thread work and returns a `ce77-live-probe-pump-v1` JSON record of the lifecycle phases seen. The operator unticks the plugin meanwhile. An observation, not a promise. | Q07 | | `ce77_live_probe_begin_synchronize()` then `ce77_live_probe_synchronize_status()` | Worker, thunk and nested-invoke managed thread IDs; return round-trip and propagated exception. | operator | @@ -154,7 +154,9 @@ disposable target through its normal cleanup route. Do not force-unload assembli - The fault switch is never read without authorization, selects exactly the requested stage, and ignores and reports an absent, unreadable or unknown switch (`LiveProbeFaultInjectionTests`). - Missing, locked or vanishing identity files are reported as typed outcomes, never as a crash - (`HostProfileObservationTests`). + (`HostProfileObservationTests`), and the bridge identity is the module the process loaded, never a file guessed next + to the application + (`HostProfileObservationTests.Host_profile_records_the_loaded_bridge_module_and_never_a_file_next_to_the_application`). - No live test is invoked by `dotnet test`, normal CI, Release validation or packaging: the solution only compiles it, and no workflow references the runner (`LocalQualificationRunnerTests.No_workflow_references_the_local_qualification_runner`). From 3292acf7c056c72ac1d5e995f77a5d14bd53c3a8 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 06:12:46 +0200 Subject: [PATCH 048/199] Regenerate lock files after adding the qualification target The S-QUAL lot adds tests/CheatEngine.SDK.QualificationTarget (Native AOT, RuntimeIdentifiers win-x64;win-x86) to the solution and moves the CE 7.7 live probe into it. Lots never commit lock files, so the target had none: the locked-mode restore of the solution and LockFileTests failed. Generated with ./eng/Update-LockFiles.ps1 (.NET SDK 10.0.401). The new lock has the net10.0/win-x64 and net10.0/win-x86 sections with runtime..Microsoft.DotNet.ILCompiler 10.0.12; every other lock, the live probe's included, is unchanged. --- .../packages.lock.json | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/CheatEngine.SDK.QualificationTarget/packages.lock.json diff --git a/tests/CheatEngine.SDK.QualificationTarget/packages.lock.json b/tests/CheatEngine.SDK.QualificationTarget/packages.lock.json new file mode 100644 index 00000000..6e9e40f6 --- /dev/null +++ b/tests/CheatEngine.SDK.QualificationTarget/packages.lock.json @@ -0,0 +1,63 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.270, )", + "resolved": "3.0.270", + "contentHash": "E2OnRZmSZ3ZLhhFj0q/En47RhWrmLEVtsRsNZIW12ecQLT9y4Wimo+A8x/kuhtzLlePJ9yPp7aPiSQx8a8H5mw==" + }, + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "AawF393Q+VkdrnrnI1gu612zh5iqpa1AGSvnKCQ3IkMgSKJXIQbO1sXYRgEzOU4f0cPZ6MaCCF29xZSGWlmbuQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "MinVer": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "AJy/KVjXgUbgjf6HiI8wAk4DSSq0SCmvXQF8aU6IB+pnIQq+YJvofvMczug2hqO8yEvnQY557ryew66KPpyCsA==" + } + }, + "net10.0/win-x64": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "AawF393Q+VkdrnrnI1gu612zh5iqpa1AGSvnKCQ3IkMgSKJXIQbO1sXYRgEzOU4f0cPZ6MaCCF29xZSGWlmbuQ==", + "dependencies": { + "runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.12" + } + }, + "runtime.win-x64.Microsoft.DotNet.ILCompiler": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "clsgU9GnioCJ+PBzQTCoJHxLXRU+7O/BzYrwRT8CUP7jgyYjNgJmNsQ/kbzAA7MG5kDvFoFvIBGHGzYdINh/EQ==" + } + }, + "net10.0/win-x86": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "AawF393Q+VkdrnrnI1gu612zh5iqpa1AGSvnKCQ3IkMgSKJXIQbO1sXYRgEzOU4f0cPZ6MaCCF29xZSGWlmbuQ==", + "dependencies": { + "runtime.win-x86.Microsoft.DotNet.ILCompiler": "10.0.12" + } + }, + "runtime.win-x86.Microsoft.DotNet.ILCompiler": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "NTcKhVn2pZ2L+pOxi9GM9tL461gK61H0g+mHIz30f7SHqk1qTWrW4Fy66RItnjeK/K5RFr64hlFSEPWFbfHksg==" + } + } + } +} \ No newline at end of file From 8baf1cba4dfee7e499b5e1a83055a7fd78219f1a Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:48:17 +0200 Subject: [PATCH 049/199] Restore locked and pin runners in the SDK workflows The pipeline restored without --locked-mode and ran on floating windows-latest/ubuntu-latest labels, so a run could resolve packages nobody reviewed and change toolchain images silently. - The composite setup-dotnet action restores a newline-separated list of solutions or projects, each with --locked-mode and an exit-code check that names ./eng/Update-LockFiles.ps1 as the fix. It gains a cache input that defaults to false: no job reachable from a release, Sonar or CodeQL run may read a cache a pull request wrote. The constant GITHUB_ENV write carries a justified inline zizmor suppression. - The aot job restores its three probes through that list instead of unlocked inline restores; sonar.yml restores locked from nuget.org before the scanner begins. - Every job runs on windows-2025 or ubuntu-24.04 with the contract timeouts (native 15, build-test 45, aot 25, lint 10, gate 5). - The callers declare defaults.run.shell: pwsh like the pipeline. WorkflowContractTests (Repository.Tests, YamlDotNet) start freezing the contract: pinned runners and timeouts, read-only top-level permissions, a pipeline that never elevates, SHA-pinned actions with version comments, checkouts without persisted credentials, exit-code checks after native commands, no expression inside run scripts, locked restores, no cache on release-reachable workflows, the composite action in every dotnet job, and no reference to the local qualification runner or to ApiCompat suppression generation. Known violations in release.yml are listed as pending and must be removed with their fix. Lock files: https://learn.microsoft.com/nuget/consume-packages/package-references-in-project-files#locking-dependencies NU1005: https://learn.microsoft.com/nuget/reference/errors-and-warnings/nu1005 --- .github/actions/setup-dotnet/action.yml | 35 ++- .github/workflows/ci.yml | 39 ++- .github/workflows/main-ci.yml | 4 + .github/workflows/pull-request-ci.yml | 4 + .github/workflows/sonar.yml | 11 +- .../CheatEngine.SDK.Repository.Tests.csproj | 5 + .../Workflows/PendingViolation.cs | 4 + .../Workflows/PipelineJob.cs | 4 + .../Workflows/Violation.cs | 4 + .../Workflows/WorkflowContract.cs | 188 ++++++++++++ .../WorkflowContractTests.Hygiene.cs | 273 ++++++++++++++++++ .../WorkflowContractTests.Restore.cs | 174 +++++++++++ .../Workflows/WorkflowContractTests.cs | 97 +++++++ .../Workflows/WorkflowFile.cs | 244 ++++++++++++++++ .../Workflows/WorkflowJob.cs | 135 +++++++++ 15 files changed, 1189 insertions(+), 32 deletions(-) create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/PendingViolation.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/PipelineJob.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/Violation.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Hygiene.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Restore.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowFile.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowJob.cs diff --git a/.github/actions/setup-dotnet/action.yml b/.github/actions/setup-dotnet/action.yml index 082d977a..5f52570d 100644 --- a/.github/actions/setup-dotnet/action.yml +++ b/.github/actions/setup-dotnet/action.yml @@ -1,28 +1,53 @@ name: Setup .NET -description: Installs the SDK pinned by global.json, quiets the CLI and optionally restores a solution or project. +description: >- + Installs the exact SDK pinned by global.json, quiets the CLI and optionally restores solutions or projects in NuGet + locked mode. inputs: restore: - description: Solution or project to restore. Empty skips the restore. + description: >- + Newline-separated solutions or projects to restore, each with --locked-mode against its committed + packages.lock.json. Empty skips the restore. default: '' + cache: + description: >- + Cache the NuGet package folder, keyed on every packages.lock.json. Keep 'false' in every job reachable from a + release, Sonar or CodeQL run (ci.yml, sonar.yml, codeql.yml, release.yml): a cache written by a pull request run + must never feed a published build. WorkflowContractTests enforces it; only scheduled-health.yml may pass 'true'. + default: 'false' runs: using: composite steps: + # global.json pins the SDK with rollForward: disable, and runner images do not ship that exact feature band, so + # every job that runs dotnet installs it here. - name: Install SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json + cache: ${{ inputs.cache }} + cache-dependency-path: '**/packages.lock.json' - name: Configure CLI shell: pwsh - run: | + run: | # zizmor: ignore[github-env] writes three constant literals only; no expression or user input reaches GITHUB_ENV 'DOTNET_NOLOGO=1', 'DOTNET_CLI_TELEMETRY_OPTOUT=1', 'MSBUILDDISABLENODEREUSE=1' | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + # Locked mode fails with NU1004 when a committed lock file no longer matches the projects, instead of silently + # resolving different packages than the ones reviewed. Never combined with --force-evaluate (NU1005). + # https://learn.microsoft.com/nuget/consume-packages/package-references-in-project-files#locking-dependencies - name: Restore if: inputs.restore != '' shell: pwsh env: - RESTORE_TARGET: ${{ inputs.restore }} - run: dotnet restore $env:RESTORE_TARGET + RESTORE_TARGETS: ${{ inputs.restore }} + run: | + $ErrorActionPreference = 'Stop' + $targets = @($env:RESTORE_TARGETS -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + foreach ($target in $targets) { + dotnet restore $target --locked-mode + if ($LASTEXITCODE -ne 0) { + throw "Locked restore of '$target' failed with exit code $LASTEXITCODE. If a dependency, global.json or a project changed, regenerate the lock files with ./eng/Update-LockFiles.ps1 and commit them." + } + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebfac757..ff90c3cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ name: CI # coverage build-test (Debug) → sonar # nuget-package build-test (Release) → sonar, release publish, reviewers # test-results- build-test → humans +# +# No job restores or saves a NuGet cache: every job is reachable from the tag release run. Every restore is locked +# against the committed packages.lock.json files (.github/actions/setup-dotnet). on: workflow_call: @@ -42,8 +45,8 @@ defaults: jobs: native: name: Build native bridge - runs-on: windows-latest - timeout-minutes: 10 + runs-on: windows-2025 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -162,8 +165,8 @@ jobs: build-test: name: Build and test (${{ matrix.configuration }}) needs: native - runs-on: windows-latest - timeout-minutes: 30 + runs-on: windows-2025 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -279,8 +282,8 @@ jobs: aot: name: Native AOT publication probe needs: native - runs-on: windows-latest - timeout-minutes: 20 + runs-on: windows-2025 + timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -288,22 +291,14 @@ jobs: fetch-depth: 0 # MinVer computes the version from tags and history persist-credentials: false + # The three projects live outside the solution's test run; their lock files carry the win-x64 ILCompiler packages. - name: Setup .NET uses: ./.github/actions/setup-dotnet with: - restore: tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj - - - name: Restore Native AOT library loader harness - run: | - $ErrorActionPreference = 'Stop' - dotnet restore tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj - if ($LASTEXITCODE -ne 0) { - throw "Native AOT library probe restore failed with exit code $LASTEXITCODE." - } - dotnet restore tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj - if ($LASTEXITCODE -ne 0) { - throw "Native AOT loader harness restore failed with exit code $LASTEXITCODE." - } + restore: | + tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj + tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj + tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj - name: Use CI-built native bridge uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -375,8 +370,8 @@ jobs: lint: name: Lint workflows - runs-on: ubuntu-latest - timeout-minutes: 5 + runs-on: ubuntu-24.04 + timeout-minutes: 10 env: ACTIONLINT_VERSION: 1.7.12 ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 # linux_amd64, official checksums file @@ -411,7 +406,7 @@ jobs: # always(): a failed or cancelled job must turn the required check red instead of skipping it. if: always() needs: [ native, build-test, aot, sonar, lint ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: { } steps: diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index baaadf1f..0997cb8f 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -12,6 +12,10 @@ on: permissions: contents: read +defaults: + run: + shell: pwsh + jobs: ci: name: CI diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index c3502c32..18a6b3a6 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -12,6 +12,10 @@ concurrency: permissions: contents: read +defaults: + run: + shell: pwsh + jobs: ci: name: CI diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 8c7d4c2c..04df00f1 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -36,7 +36,7 @@ defaults: jobs: analyze: name: Analyze - runs-on: windows-latest + runs-on: windows-2025 timeout-minutes: 30 env: SONAR_PROJECT_KEY: ${{ inputs.project-key }} @@ -68,7 +68,7 @@ jobs: distribution: zulu java-version: '21' - # SDK and CLI settings only: the restore below must not use the checked-out nuget.config. + # SDK and CLI settings only: the restore below must not use the checked-out nuget.config. No package cache. - name: Setup .NET uses: ./.github/actions/setup-dotnet @@ -103,14 +103,15 @@ jobs: "path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 # Restore before scanner begin: the subsequent analysis build is --no-restore, so no PR-supplied source can - # participate while scanner credentials are configured. + # participate while scanner credentials are configured. Locked mode: exactly the packages of the committed lock + # files, from nuget.org only. - name: Restore from NuGet.org env: NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} run: | - dotnet restore CheatEngine.SDK.slnx --configfile $env:NUGET_CONFIG + dotnet restore CheatEngine.SDK.slnx --configfile $env:NUGET_CONFIG --locked-mode if ($LASTEXITCODE -ne 0) { - throw "Sonar restore failed with exit code $LASTEXITCODE." + throw "Sonar restore failed with exit code $LASTEXITCODE. If a dependency changed, regenerate the lock files with ./eng/Update-LockFiles.ps1." } - name: Install scanner diff --git a/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj index 2edfc5ad..e2512499 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj +++ b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj @@ -10,4 +10,9 @@ + + + + + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/PendingViolation.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/PendingViolation.cs new file mode 100644 index 00000000..ebf3a626 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/PendingViolation.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// A known violation in a file another work item owns, removed from the list together with its fix. +internal sealed record PendingViolation(string File, string Rule, string Subject, string Reason); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/PipelineJob.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/PipelineJob.cs new file mode 100644 index 00000000..0485e6db --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/PipelineJob.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// A ci.yml job of shared contract 1.6. Runner and timeout are null for a reusable-workflow call. +internal sealed record PipelineJob(string Id, string Name, string? RunsOn, int? TimeoutMinutes); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/Violation.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/Violation.cs new file mode 100644 index 00000000..f8eb92c5 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/Violation.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// A rule violation: the file, the subject (usually a job id) and a message naming the fix. +internal sealed record Violation(string File, string Subject, string Message); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs new file mode 100644 index 00000000..ecf35971 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs @@ -0,0 +1,188 @@ +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// The frozen CI interface (shared contract section 1): job ids and names, runners, timeouts, the Sonar expectation, +/// the reserved artifact names. Changing a value here is a contract change, reviewed with the workflow it describes. +/// +internal static class WorkflowContract +{ + /// The reusable pipeline. + public const string Pipeline = "ci.yml"; + + /// The reusable Sonar workflow. + public const string Sonar = "sonar.yml"; + + /// The composite action every dotnet job uses. + public const string SetupAction = "./.github/actions/setup-dotnet"; + + /// The composite action file. + public const string SetupActionFile = ".github/actions/setup-dotnet/action.yml"; + + /// The caller job id that, with , produces the required check "CI / Gate". + public const string CallerJobId = "ci"; + + /// The caller job name. + public const string CallerJobName = "CI"; + + /// The gate job id. + public const string GateJobId = "gate"; + + /// The gate job name. + public const string GateJobName = "Gate"; + + /// SONAR_EXPECTED (contract 1.7), whitespace-normalized. + public const string SonarExpected = + "${{ inputs.sonar && github.event_name != 'merge_group' && github.actor != 'dependabot[bot]' && " + + "(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}"; + + /// The environment variable naming the exact nupkg the Release leg packed (contract 1.8). + public const string ExactPackageVariable = "CESDK_PACKAGED_UMBRELLA_NUPKG"; + + /// The xUnit v3 trait the Debug leg filters out: the packaging tests run in the Release leg only. + public const string PackagingTrait = "Category=Packaging"; + + /// The C0-only live probe: compiled by CI, never loaded or run. + public const string LiveProbeProject = "tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj"; + + /// Retention of every binlogs-* artifact. + public const string BinlogRetention = "5"; + + /// The only runner labels a job may use (never a floating -latest label). + public static readonly HashSet RunnerLabels = new(StringComparer.Ordinal) { "windows-2025", "ubuntu-24.04" }; + + /// The workflows that call ci.yml and so produce "CI / Gate". + public static readonly string[] Callers = ["pull-request-ci.yml", "main-ci.yml", "release.yml"]; + + /// The workflows of the pipeline itself, which declare defaults.run.shell: pwsh. + public static readonly string[] PipelineWorkflows = [Pipeline, Sonar, "main-ci.yml", "pull-request-ci.yml"]; + + /// Workflows whose every job is reachable from a release, Sonar or CodeQL run: no package cache there. + public static readonly string[] CacheFreeWorkflows = [Pipeline, Sonar, "codeql.yml", "release.yml"]; + + /// Jobs that may be absent from gate.needs: advisory, continue-on-error: true. + public static readonly HashSet AdvisoryJobs = new(StringComparer.Ordinal) { "client-canary" }; + + /// The jobs of ci.yml after Wave 1 (contract 1.6): id, name, runner and timeout in minutes. + public static readonly PipelineJob[] Jobs = + [ + new("native", "Build native bridge", "windows-2025", 15), + new("build-test", "Build and test (${{ matrix.configuration }})", "windows-2025", 45), + new("aot", "Native AOT publication probe", "windows-2025", 25), + new("sonar", "Sonar", null, null), + new("lint", "Lint", "ubuntu-24.04", 10), + new("format", "Format", "ubuntu-24.04", 10), + new("dependency-review", "Dependency review", "ubuntu-24.04", 10), + new("lock-files", "Lock files", "windows-2025", 15), + new(GateJobId, GateJobName, "ubuntu-24.04", 5) + ]; + + /// Job ids reserved for later waves, accepted with exactly these names once the integrator wires them. + public static readonly Dictionary ReservedJobs = new(StringComparer.Ordinal) + { + ["native-host-emulator"] = "Native host emulator", + ["lua-surface"] = "Lua surface catalogue", + ["client-canary"] = "Client canary (advisory)", + ["examples"] = "Compile examples" + }; + + /// + /// Every artifact name a workflow may upload (contract 1.9 with the orchestrator's attestation-bundles and + /// dependency-snapshot), with its retention in days, or null where the contract leaves it to the producer. + /// {configuration} is Debug or + /// Release; binlogs-* names are checked by pattern with . + /// + public static readonly Dictionary ReservedArtifacts = new(StringComparer.Ordinal) + { + ["lua-protection-bridge"] = "14", + ["classic-abi-fixture-facts"] = "14", + ["nuget-package"] = "${{ inputs.package-retention-days }}", + ["build-info"] = "${{ inputs.package-retention-days }}", + ["coverage"] = "7", + ["coverage-report"] = "7", + ["test-results-{configuration}"] = "7", + ["test-dumps-{configuration}"] = "5", + ["release-notes"] = "90", + ["native-host-emulator"] = "14", + ["lua-surface-report"] = "30", + ["client-canary-report"] = "14", + ["attestation-bundles"] = null, + // The advisory dependency-submission.yml hands its snapshot from the detect job to the submit job. + ["dependency-snapshot"] = "5" + }; + + /// + /// Known violations in files other work items own, each with the work that removes it. The list only shrinks: a + /// test fails when an entry no longer matches a violation, so the entry is deleted in the commit that fixes it. + /// + public static readonly PendingViolation[] Pending = + [ + new("release.yml", Rules.Runner, "verify", + "The release workflow rework (contract 1.11) moves every job to windows-2025 or ubuntu-24.04."), + new("release.yml", Rules.Runner, "publish", + "The release workflow rework (contract 1.11) moves every job to windows-2025 or ubuntu-24.04."), + new("release.yml", Rules.Runner, "github-release", + "The release workflow rework (contract 1.11) replaces this job with draft-release and finalize-release."), + new("release.yml", Rules.DotnetSetup, "publish", + "The publish job runs dotnet nuget push without the pinned SDK; the release workflow rework adds the composite action."), + new("release.yml", Rules.ExitCode, "github-release", + "The release workflow rework removes the gh release upload fallback (contract 1.11) and this branching.") + ]; + + /// Reports minus the pending ones, and pending entries that no longer match. + public static void AssertNoViolations(string rule, IReadOnlyCollection violations) + { + List unexpected = []; + HashSet matched = []; + foreach (Violation violation in violations) + { + PendingViolation? pending = FindPending(rule, violation); + if (pending is null) + { + unexpected.Add(violation.Message); + } + else + { + matched.Add(pending); + } + } + + foreach (PendingViolation pending in Pending) + { + if (string.Equals(pending.Rule, rule, StringComparison.Ordinal) && !matched.Contains(pending)) + { + unexpected.Add( + $"The pending {rule} violation {pending.File} '{pending.Subject}' is fixed: remove it from {nameof(WorkflowContract)}.{nameof(Pending)}."); + } + } + + Assert.True(unexpected.Count == 0, string.Join(Environment.NewLine, unexpected)); + } + + private static PendingViolation? FindPending(string rule, Violation violation) + { + foreach (PendingViolation pending in Pending) + { + if (string.Equals(pending.Rule, rule, StringComparison.Ordinal) && + string.Equals(pending.File, violation.File, StringComparison.Ordinal) && + string.Equals(pending.Subject, violation.Subject, StringComparison.Ordinal)) + { + return pending; + } + } + + return null; + } + + /// Rule names used by . + public static class Rules + { + /// A job without a pinned runner label or a timeout. + public const string Runner = "runner"; + + /// A job that runs dotnet without the composite setup action. + public const string DotnetSetup = "dotnet-setup"; + + /// A native command whose exit code is not checked on the next line. + public const string ExitCode = "exit-code"; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Hygiene.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Hygiene.cs new file mode 100644 index 00000000..d97272ce --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Hygiene.cs @@ -0,0 +1,273 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// Rules for every workflow and composite action: runners, timeouts, permissions, pins and run scripts. +public sealed partial class WorkflowContractTests +{ + [Fact] + public void Every_job_has_a_timeout_and_a_pinned_runner_label() + { + List violations = []; + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + foreach (WorkflowJob job in workflow.Jobs()) + { + // GitHub rejects runs-on and timeout-minutes on a reusable-workflow call; the callee's jobs are checked. + if (job.CallsReusableWorkflow) + { + continue; + } + + if (job.RunsOn is null || !WorkflowContract.RunnerLabels.Contains(job.RunsOn)) + { + violations.Add(new Violation(workflow.FileName, job.Id, + $"{job.Location} runs on '{job.RunsOn}': use a literal windows-2025 or ubuntu-24.04 label, never -latest or an expression.")); + } + + string? timeout = WorkflowFile.Scalar(job.Node, "timeout-minutes"); + if (!int.TryParse(timeout, NumberStyles.None, CultureInfo.InvariantCulture, out int minutes) || minutes <= 0) + { + violations.Add(new Violation(workflow.FileName, job.Id, + $"{job.Location} has no positive timeout-minutes ('{timeout}').")); + } + } + } + + WorkflowContract.AssertNoViolations(WorkflowContract.Rules.Runner, violations); + } + + [Fact] + public void Workflows_grant_only_read_permissions_at_the_top_level() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + Assert.True(workflow.Root.Children.TryGetValue(new YamlScalarNode("permissions"), out YamlNode? permissions), + $"{workflow.FileName} must declare top-level permissions (contents: read) and elevate per job only."); + if (permissions is YamlScalarNode scalar) + { + Assert.True(string.Equals(scalar.Value, "read-all", StringComparison.Ordinal), + $"{workflow.FileName} grants '{scalar.Value}' to every job; elevate in the job that needs it."); + continue; + } + + YamlMappingNode scopes = Assert.IsType(permissions); + foreach (KeyValuePair scope in scopes.Children) + { + string value = ((YamlScalarNode) scope.Value).Value ?? ""; + Assert.True(value is "read" or "none", + $"{workflow.FileName} grants '{scope.Key}: {value}' to every job; elevate in the job that needs it."); + } + + if (Array.IndexOf(WorkflowContract.PipelineWorkflows, workflow.FileName) >= 0) + { + Assert.Equal(["contents"], WorkflowFile.Keys(scopes)); + Assert.Equal("read", WorkflowFile.Scalar(scopes, "contents")); + } + } + } + + [Fact] + public void Pipeline_jobs_never_elevate_permissions() + { + // ci.yml and sonar.yml run pull-request code: no job there may hold a write scope (contract 1.6). + foreach (string fileName in new[] { WorkflowContract.Pipeline, WorkflowContract.Sonar }) + { + foreach (WorkflowJob job in WorkflowFile.LoadWorkflow(fileName).Jobs()) + { + if (!job.Node.Children.TryGetValue(new YamlScalarNode("permissions"), out YamlNode? permissions)) + { + continue; + } + + YamlMappingNode scopes = Assert.IsType(permissions); + foreach (KeyValuePair scope in scopes.Children) + { + string value = ((YamlScalarNode) scope.Value).Value ?? ""; + Assert.True(value is "read" or "none", $"{job.Location} grants '{scope.Key}: {value}'."); + } + } + } + } + + [Fact] + public void Every_remote_action_is_pinned_to_a_full_sha_with_a_version_comment() + { + Dictionary pins = new(StringComparer.Ordinal); + foreach (WorkflowFile file in AllActionFiles()) + { + foreach (string line in file.Text.Split('\n')) + { + Match uses = UsesLine().Match(line); + if (!uses.Success || uses.Groups["reference"].Value.StartsWith("./", StringComparison.Ordinal)) + { + continue; + } + + Match pinned = PinnedReference().Match(uses.Groups["reference"].Value + uses.Groups["rest"].Value); + Assert.True(pinned.Success, + $"{file.RelativePath}: '{line.Trim()}' must be owner/repo@<40-hex commit> # vX.Y.Z (the tag of that commit)."); + + // One action, one pin: the same action at two commits would be two supply-chain inputs to review. + string action = pinned.Groups["action"].Value; + string sha = pinned.Groups["sha"].Value; + if (pins.TryGetValue(action, out string? other)) + { + Assert.True(string.Equals(other, sha, StringComparison.Ordinal), + $"{action} is pinned to both {other} and {sha}; use one commit everywhere."); + } + else + { + pins.Add(action, sha); + } + } + } + + Assert.NotEmpty(pins); + } + + [Fact] + public void Every_checkout_disables_credential_persistence() + { + int checkouts = 0; + foreach (WorkflowFile file in AllActionFiles()) + { + foreach (YamlMappingNode step in AllSteps(file)) + { + string? uses = WorkflowFile.Scalar(step, "uses"); + if (uses is null || !uses.StartsWith("actions/checkout@", StringComparison.Ordinal)) + { + continue; + } + + checkouts++; + Assert.True(string.Equals(WorkflowJob.With(step, "persist-credentials"), "false", StringComparison.Ordinal), + $"{file.RelativePath} line {step.Start.Line}: actions/checkout must set persist-credentials: false."); + } + } + + Assert.True(checkouts > 0, "No actions/checkout step was found."); + } + + [Fact] + public void Every_native_command_in_a_workflow_script_checks_its_exit_code() + { + List violations = []; + foreach (WorkflowFile file in AllActionFiles()) + { + foreach ((string subject, YamlMappingNode step) in AllStepsWithSubject(file)) + { + if (WorkflowFile.Scalar(step, "run") is not { } run) + { + continue; + } + + string[] lines = run.Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + if (!NativeInvocation().IsMatch(lines[index])) + { + continue; + } + + // A command continued with a trailing backtick ends on its last continued line. + int last = index; + while (last + 1 < lines.Length && lines[last].TrimEnd().EndsWith('`')) + { + last++; + } + + int next = last + 1; + while (next < lines.Length && string.IsNullOrWhiteSpace(lines[next])) + { + next++; + } + + if (next >= lines.Length || + !lines[next].TrimStart().StartsWith("if ($LASTEXITCODE -ne 0)", StringComparison.Ordinal)) + { + violations.Add(new Violation(file.FileName, subject, + $"{file.RelativePath} ({subject}): '{lines[index].Trim()}' must be followed by if ($LASTEXITCODE -ne 0) {{ throw ... }}.")); + } + } + } + } + + WorkflowContract.AssertNoViolations(WorkflowContract.Rules.ExitCode, violations); + } + + [Fact] + public void No_run_script_interpolates_an_expression() + { + foreach (WorkflowFile file in AllActionFiles()) + { + foreach (YamlMappingNode step in AllSteps(file)) + { + string run = WorkflowFile.Scalar(step, "run") ?? ""; + Assert.False(run.Contains("${{", StringComparison.Ordinal), + $"{file.RelativePath} line {step.Start.Line}: pass expressions to run scripts through env:, never inline (template injection)."); + } + } + } + + [Fact] + public void Pipeline_workflows_and_composite_actions_run_scripts_in_pwsh() + { + foreach (string fileName in WorkflowContract.PipelineWorkflows) + { + WorkflowFile workflow = WorkflowFile.LoadWorkflow(fileName); + YamlMappingNode? defaults = WorkflowFile.Mapping(workflow.Root, "defaults"); + YamlMappingNode? run = defaults is null ? null : WorkflowFile.Mapping(defaults, "run"); + Assert.True(run is not null && string.Equals(WorkflowFile.Scalar(run, "shell"), "pwsh", StringComparison.Ordinal), + $"{fileName} must declare defaults.run.shell: pwsh."); + } + + foreach (WorkflowFile action in WorkflowFile.LoadActions()) + { + foreach (YamlMappingNode step in action.ActionSteps()) + { + if (WorkflowFile.Has(step, "run")) + { + Assert.True(string.Equals(WorkflowFile.Scalar(step, "shell"), "pwsh", StringComparison.Ordinal), + $"{action.RelativePath} line {step.Start.Line}: a composite run step must declare shell: pwsh."); + } + } + } + } + + [Fact] + public void No_workflow_references_the_local_qualification_runner() + { + foreach (WorkflowFile file in AllActionFiles()) + { + // The exact-host runner starts Cheat Engine; CI never does (levels C0-C2 only, audit ch.20). + Assert.False(file.Text.Contains("eng/qualification", StringComparison.OrdinalIgnoreCase), + $"{file.RelativePath} references eng/qualification: the local qualification runner never runs in CI."); + } + } + + [Fact] + public void No_workflow_passes_ApiCompatGenerateSuppressionFile() + { + foreach (WorkflowFile file in AllActionFiles()) + { + // Suppressions are regenerated by the integrator only; CI must fail on an undeclared break instead. + Assert.False(file.Text.Contains("ApiCompatGenerateSuppressionFile", StringComparison.OrdinalIgnoreCase) || + file.Text.Contains("GenerateCompatibilitySuppressionFile", StringComparison.OrdinalIgnoreCase), + $"{file.RelativePath} generates an ApiCompat suppression file; CI must fail on an undeclared break instead."); + } + } + + [GeneratedRegex(@"^\s*(?:-\s+)?uses:\s*(?\S+)(?.*)$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex UsesLine(); + + [GeneratedRegex(@"^(?[A-Za-z0-9-]+/[A-Za-z0-9._/-]+)@(?[0-9a-f]{40}) # v\d+\.\d+\.\d+\s*$", + RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PinnedReference(); + + [GeneratedRegex(@"^\s*(?:dotnet|xmake|git|gh|tar)\s|^\s*\./|^\s*&\s", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex NativeInvocation(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Restore.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Restore.cs new file mode 100644 index 00000000..e979168a --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Restore.cs @@ -0,0 +1,174 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// Locked restores through the composite action, the pinned SDK, and no package cache (contract 1.5). +public sealed partial class WorkflowContractTests +{ + [Fact] + public void Composite_setup_restores_in_locked_mode() + { + WorkflowFile action = SetupAction(); + + YamlMappingNode inputs = Assert.IsType(WorkflowFile.Mapping(action.Root, "inputs")); + Assert.Equal("false", WorkflowFile.Scalar(Assert.IsType(WorkflowFile.Mapping(inputs, "cache")), "default")); + Assert.Equal("", WorkflowFile.Scalar(Assert.IsType(WorkflowFile.Mapping(inputs, "restore")), "default")); + + IReadOnlyList steps = action.ActionSteps(); + YamlMappingNode install = Assert.Single(steps, + static step => (WorkflowFile.Scalar(step, "uses") ?? "").StartsWith("actions/setup-dotnet@", StringComparison.Ordinal)); + Assert.Equal("global.json", WorkflowJob.With(install, "global-json-file")); + Assert.Equal("${{ inputs.cache }}", WorkflowJob.With(install, "cache")); + Assert.Equal("**/packages.lock.json", WorkflowJob.With(install, "cache-dependency-path")); + + YamlMappingNode restore = Assert.Single(steps, + static step => string.Equals(WorkflowFile.Scalar(step, "name"), "Restore", StringComparison.Ordinal)); + Assert.Equal("inputs.restore != ''", WorkflowFile.Scalar(restore, "if")); + Assert.Equal("${{ inputs.restore }}", WorkflowJob.Env(restore, "RESTORE_TARGETS")); + string run = WorkflowFile.Scalar(restore, "run") ?? ""; + // One locked restore per listed solution or project, each checked, with the fix in the message. + Assert.Contains("-split \"`n\"", run, StringComparison.Ordinal); + Assert.Contains("foreach ($target in $targets)", run, StringComparison.Ordinal); + Assert.Contains("dotnet restore $target --locked-mode", run, StringComparison.Ordinal); + Assert.Contains("./eng/Update-LockFiles.ps1", run, StringComparison.Ordinal); + // NU1005: locked mode and force-evaluate cannot be combined. + Assert.DoesNotContain("--force-evaluate", run, StringComparison.Ordinal); + } + + [Fact] + public void Every_restore_in_the_pipeline_is_locked() + { + List unlocked = []; + foreach (string fileName in new[] { WorkflowContract.Pipeline, WorkflowContract.Sonar }) + { + foreach (WorkflowJob job in WorkflowFile.LoadWorkflow(fileName).Jobs()) + { + foreach (string line in StripComments(job.RunText()).Split('\n')) + { + if (DotnetRestore().IsMatch(line) && !line.Contains("--locked-mode", StringComparison.Ordinal)) + { + unlocked.Add($"{job.Location}: {line.Trim()}"); + } + } + } + } + + Assert.True(unlocked.Count == 0, + "Restore with --locked-mode, or through the composite action's restore input: " + string.Join("; ", unlocked)); + } + + [Fact] + public void Release_reachable_workflows_never_enable_a_package_cache() + { + foreach (string fileName in WorkflowContract.CacheFreeWorkflows) + { + WorkflowFile? workflow = WorkflowFile.TryLoadWorkflow(fileName); + if (workflow is null) + { + continue; + } + + foreach (WorkflowJob job in workflow.Jobs()) + { + foreach (YamlMappingNode step in job.Steps) + { + string uses = WorkflowFile.Scalar(step, "uses") ?? ""; + Assert.False(uses.StartsWith("actions/cache", StringComparison.Ordinal), + $"{job.Location} uses {uses}: a cache written by a pull request run must never feed a release build."); + string? cache = WorkflowJob.With(step, "cache"); + Assert.True(cache is null || string.Equals(cache, "false", StringComparison.Ordinal), + $"{job.Location} passes cache: {cache}; every job of {fileName} is reachable from a release, Sonar or CodeQL run."); + } + } + } + } + + [Fact] + public void Every_dotnet_job_uses_the_composite_setup_action() + { + List violations = []; + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + foreach (WorkflowJob job in workflow.Jobs()) + { + int firstDotnetStep = -1; + int setupStep = -1; + IReadOnlyList steps = job.Steps; + for (int index = 0; index < steps.Count; index++) + { + if (setupStep < 0 && + string.Equals(WorkflowFile.Scalar(steps[index], "uses"), WorkflowContract.SetupAction, StringComparison.Ordinal)) + { + setupStep = index; + } + + if (firstDotnetStep < 0 && WorkflowFile.Scalar(steps[index], "run") is { } run && RunsDotnet(run)) + { + firstDotnetStep = index; + } + } + + if (firstDotnetStep >= 0 && (setupStep < 0 || setupStep > firstDotnetStep)) + { + violations.Add(new Violation(workflow.FileName, job.Id, + $"{job.Location} runs dotnet without first using {WorkflowContract.SetupAction}: global.json pins the SDK with rollForward: disable and runner images do not ship it.")); + } + } + } + + WorkflowContract.AssertNoViolations(WorkflowContract.Rules.DotnetSetup, violations); + } + + [Fact] + public void Sonar_restores_locked_from_nuget_org_before_the_scanner_begins() + { + WorkflowJob analyze = WorkflowFile.LoadWorkflow(WorkflowContract.Sonar).Job("analyze"); + + int restore = analyze.StepIndex("Restore from NuGet.org"); + int begin = analyze.StepIndex("Begin analysis"); + int build = analyze.StepIndex("Build"); + Assert.True(restore >= 0 && restore < begin && begin < build, + "sonar.yml must restore before the scanner begins, then build without restoring: no pull-request-controlled source may run while scanner credentials are configured."); + string restoreRun = WorkflowFile.Scalar(analyze.Steps[restore], "run") ?? ""; + Assert.Contains("--configfile $env:NUGET_CONFIG", restoreRun, StringComparison.Ordinal); + Assert.Contains("--locked-mode", restoreRun, StringComparison.Ordinal); + Assert.Contains("--no-restore", WorkflowFile.Scalar(analyze.Steps[build], "run"), StringComparison.Ordinal); + + // The composite action installs the SDK only: the checked-out nuget.config never resolves the scanner packages. + YamlMappingNode setup = Assert.Single(analyze.StepsUsing(WorkflowContract.SetupAction)); + Assert.Null(WorkflowJob.With(setup, "restore")); + } + + /// Whether a run script, or a repository script it invokes, runs the dotnet CLI. + private static bool RunsDotnet(string run) + { + if (DotnetInvocation().IsMatch(StripComments(run))) + { + return true; + } + + foreach (Match script in ScriptReference().Matches(run)) + { + string path = Path.Combine(RepositoryRoot.Path, script.Groups["path"].Value); + if (File.Exists(path) && DotnetInvocation().IsMatch(StripComments(File.ReadAllText(path)))) + { + return true; + } + } + + return false; + } + + [GeneratedRegex(@"(?m)(?:^|[\s;(|{&])dotnet\s", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex DotnetInvocation(); + + [GeneratedRegex(@"\bdotnet\s+restore\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex DotnetRestore(); + + [GeneratedRegex(@"(?:^|[\s(])\.?/?(?(?:eng|tests)/[\w./-]+\.ps1)\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ScriptReference(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.cs new file mode 100644 index 00000000..8f679c45 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.cs @@ -0,0 +1,97 @@ +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// Freezes the CI interface of shared contract section 1 over the workflows and composite actions that exist, so a +/// workflow added later is checked on arrival. Each WorkflowContractTests.<Concern>.cs file covers one +/// concern of the contract (hygiene, restores, jobs, lint, gate); this file holds the shared readers. +/// +public sealed partial class WorkflowContractTests +{ + private static WorkflowFile Pipeline() + { + return WorkflowFile.LoadWorkflow(WorkflowContract.Pipeline); + } + + private static WorkflowFile SetupAction() + { + foreach (WorkflowFile action in WorkflowFile.LoadActions()) + { + if (string.Equals(action.RelativePath, WorkflowContract.SetupActionFile, StringComparison.Ordinal)) + { + return action; + } + } + + Assert.Fail($"The composite action {WorkflowContract.SetupActionFile} is missing."); + return null!; + } + + private static string ReadRepositoryText(string relativePath) + { + string path = Path.Combine(RepositoryRoot.Path, relativePath); + Assert.True(File.Exists(path), $"{relativePath} is missing."); + return File.ReadAllText(path).Replace("\r\n", "\n", StringComparison.Ordinal); + } + + /// Every workflow and composite action. + private static List AllActionFiles() + { + return [.. WorkflowFile.LoadWorkflows(), .. WorkflowFile.LoadActions()]; + } + + private static List AllSteps(WorkflowFile file) + { + List steps = []; + foreach ((string _, YamlMappingNode step) in AllStepsWithSubject(file)) + { + steps.Add(step); + } + + return steps; + } + + /// Every step with its job id (workflows) or composite (actions). + private static List<(string Subject, YamlMappingNode Step)> AllStepsWithSubject(WorkflowFile file) + { + List<(string, YamlMappingNode)> steps = []; + foreach (WorkflowJob job in file.Jobs()) + { + foreach (YamlMappingNode step in job.Steps) + { + steps.Add((job.Id, step)); + } + } + + foreach (YamlMappingNode step in file.ActionSteps()) + { + steps.Add(("composite", step)); + } + + return steps; + } + + /// A PowerShell script without its comment lines and comment blocks. + private static string StripComments(string script) + { + string withoutBlocks = CommentBlock().Replace(script, ""); + List lines = []; + foreach (string line in withoutBlocks.Split('\n')) + { + if (!line.TrimStart().StartsWith('#')) + { + lines.Add(line); + } + } + + return string.Join('\n', lines); + } + + [GeneratedRegex(@"<#.*?#>", RegexOptions.Singleline | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex CommentBlock(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowFile.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowFile.cs new file mode 100644 index 00000000..eba296f6 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowFile.cs @@ -0,0 +1,244 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// One GitHub Actions YAML file (a workflow or a composite action) with its raw text, for rules that need comments, +/// and its YAML tree. Keys are read as plain strings (on is never a boolean), and a missing key is null. +/// +internal sealed class WorkflowFile +{ + private const string WorkflowDirectory = ".github/workflows"; + private const string ActionDirectory = ".github/actions"; + + private WorkflowFile(string relativePath, string text, YamlMappingNode root) + { + RelativePath = relativePath; + Text = text; + Root = root; + } + + /// The repository-relative path with forward slashes. + public string RelativePath + { + get; + } + + /// The file name, for example ci.yml. + public string FileName => Path.GetFileName(RelativePath); + + /// The raw text with LF line endings. + public string Text + { + get; + } + + /// The document's root mapping. + public YamlMappingNode Root + { + get; + } + + /// Every workflow under .github/workflows that exists now, so new workflows are checked on arrival. + public static IReadOnlyList LoadWorkflows() + { + return LoadDirectory(WorkflowDirectory, "*.y*ml"); + } + + /// Every composite action under .github/actions. + public static IReadOnlyList LoadActions() + { + return LoadDirectory(ActionDirectory, "action.y*ml"); + } + + /// A workflow by file name, or null when it does not exist. + public static WorkflowFile? TryLoadWorkflow(string fileName) + { + string path = Path.Combine(RepositoryRoot.Path, WorkflowDirectory, fileName); + return File.Exists(path) ? Load(path) : null; + } + + /// A workflow that must exist. + public static WorkflowFile LoadWorkflow(string fileName) + { + WorkflowFile? workflow = TryLoadWorkflow(fileName); + Assert.True(workflow is not null, $"{WorkflowDirectory}/{fileName} does not exist."); + return workflow; + } + + /// The jobs of a workflow, in file order. + public IReadOnlyList Jobs() + { + List jobs = []; + YamlMappingNode? node = Mapping(Root, "jobs"); + if (node is null) + { + return jobs; + } + + foreach (KeyValuePair entry in node.Children) + { + jobs.Add(new WorkflowJob(this, ((YamlScalarNode) entry.Key).Value!, (YamlMappingNode) entry.Value)); + } + + return jobs; + } + + /// A job that must exist. + public WorkflowJob Job(string id) + { + foreach (WorkflowJob job in Jobs()) + { + if (string.Equals(job.Id, id, StringComparison.Ordinal)) + { + return job; + } + } + + Assert.Fail($"{RelativePath} has no job '{id}'."); + return null!; + } + + /// The event names of the on key, whether it is a scalar, a sequence or a mapping. + public IReadOnlyList Triggers() + { + if (!Root.Children.TryGetValue(new YamlScalarNode("on"), out YamlNode? on)) + { + return []; + } + + return on switch + { + YamlScalarNode scalar => [scalar.Value!], + YamlSequenceNode sequence => ScalarValues(sequence), + YamlMappingNode mapping => Keys(mapping), + _ => [] + }; + } + + /// The configuration of one trigger, or null when it has none. + public YamlMappingNode? Trigger(string eventName) + { + return Mapping(Root, "on") is { } on ? Mapping(on, eventName) : null; + } + + /// The steps of a composite action. + public IReadOnlyList ActionSteps() + { + YamlMappingNode? runs = Mapping(Root, "runs"); + return runs is null ? [] : Mappings(Sequence(runs, "steps")); + } + + /// A child scalar's value, or null. + public static string? Scalar(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) && value is YamlScalarNode scalar + ? scalar.Value + : null; + } + + /// A child mapping, or null. + public static YamlMappingNode? Mapping(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) + ? value as YamlMappingNode + : null; + } + + /// A child sequence, or null. + public static YamlSequenceNode? Sequence(YamlMappingNode node, string key) + { + return node.Children.TryGetValue(new YamlScalarNode(key), out YamlNode? value) + ? value as YamlSequenceNode + : null; + } + + /// Whether the mapping has the key, whatever its value. + public static bool Has(YamlMappingNode node, string key) + { + return node.Children.ContainsKey(new YamlScalarNode(key)); + } + + /// The keys of a mapping, in order. + public static List Keys(YamlMappingNode node) + { + List keys = []; + foreach (YamlNode key in node.Children.Keys) + { + keys.Add(((YamlScalarNode) key).Value!); + } + + return keys; + } + + /// The mapping items of a sequence (a null sequence is empty). + public static List Mappings(YamlSequenceNode? sequence) + { + List items = []; + if (sequence is null) + { + return items; + } + + foreach (YamlNode item in sequence.Children) + { + if (item is YamlMappingNode mapping) + { + items.Add(mapping); + } + } + + return items; + } + + /// The scalar items of a sequence. + public static List ScalarValues(YamlSequenceNode sequence) + { + List values = []; + foreach (YamlNode item in sequence.Children) + { + if (item is YamlScalarNode scalar) + { + values.Add(scalar.Value!); + } + } + + return values; + } + + /// Collapses every run of whitespace to one space and trims, for comparing folded expressions. + public static string NormalizeWhitespace(string value) + { + return string.Join(' ', value.Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries)); + } + + private static List LoadDirectory(string relativeDirectory, string pattern) + { + List files = []; + string directory = Path.Combine(RepositoryRoot.Path, relativeDirectory); + if (!Directory.Exists(directory)) + { + return files; + } + + foreach (string path in Directory.EnumerateFiles(directory, pattern, SearchOption.AllDirectories)) + { + files.Add(Load(path)); + } + + files.Sort(static (left, right) => string.CompareOrdinal(left.RelativePath, right.RelativePath)); + return files; + } + + private static WorkflowFile Load(string path) + { + string text = File.ReadAllText(path).Replace("\r\n", "\n", StringComparison.Ordinal); + YamlStream stream = []; + stream.Load(new StringReader(text)); + Assert.Single(stream.Documents); + YamlMappingNode root = Assert.IsType(stream.Documents[0].RootNode); + return new WorkflowFile(RepositoryRoot.ToRelative(path), text, root); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowJob.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowJob.cs new file mode 100644 index 00000000..c38c05dc --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowJob.cs @@ -0,0 +1,135 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// One job of a workflow: either a runner job with steps, or a call to a reusable workflow (uses:). +internal sealed class WorkflowJob +{ + internal WorkflowJob(WorkflowFile workflow, string id, YamlMappingNode node) + { + Workflow = workflow; + Id = id; + Node = node; + } + + /// The file that declares the job. + public WorkflowFile Workflow + { + get; + } + + /// The job id (its key under jobs). + public string Id + { + get; + } + + /// The job's mapping. + public YamlMappingNode Node + { + get; + } + + /// The display name, or null. + public string? Name => WorkflowFile.Scalar(Node, "name"); + + /// The if: condition, or null. + public string? Condition => WorkflowFile.Scalar(Node, "if"); + + /// The runner label when it is a scalar, or null. + public string? RunsOn => WorkflowFile.Scalar(Node, "runs-on"); + + /// The reusable workflow this job calls, or null for a runner job. + public string? Uses => WorkflowFile.Scalar(Node, "uses"); + + /// Whether the job calls a reusable workflow (GitHub rejects runs-on and timeout-minutes there). + public bool CallsReusableWorkflow => Uses is not null; + + /// A readable locator for messages. + public string Location => $"{Workflow.FileName} job '{Id}'"; + + /// The steps, in order. + public IReadOnlyList Steps => WorkflowFile.Mappings(WorkflowFile.Sequence(Node, "steps")); + + /// The job ids this job needs, whether needs is a scalar or a sequence. + public IReadOnlyList Needs() + { + if (!Node.Children.TryGetValue(new YamlScalarNode("needs"), out YamlNode? needs)) + { + return []; + } + + return needs switch + { + YamlScalarNode scalar => [scalar.Value!], + YamlSequenceNode sequence => WorkflowFile.ScalarValues(sequence), + _ => [] + }; + } + + /// The step with the given name, which must exist. + public YamlMappingNode Step(string name) + { + int index = StepIndex(name); + Assert.True(index >= 0, $"{Location} has no step named '{name}'."); + return Steps[index]; + } + + /// The index of the step with the given name, or -1. + public int StepIndex(string name) + { + IReadOnlyList steps = Steps; + for (int index = 0; index < steps.Count; index++) + { + if (string.Equals(WorkflowFile.Scalar(steps[index], "name"), name, StringComparison.Ordinal)) + { + return index; + } + } + + return -1; + } + + /// The steps whose uses: starts with the given action reference (for example actions/checkout@). + public List StepsUsing(string actionPrefix) + { + List matches = []; + foreach (YamlMappingNode step in Steps) + { + string? uses = WorkflowFile.Scalar(step, "uses"); + if (uses is not null && uses.StartsWith(actionPrefix, StringComparison.Ordinal)) + { + matches.Add(step); + } + } + + return matches; + } + + /// Every run: script of the job, joined, for text rules. + public string RunText() + { + List scripts = []; + foreach (YamlMappingNode step in Steps) + { + if (WorkflowFile.Scalar(step, "run") is { } run) + { + scripts.Add(run); + } + } + + return string.Join('\n', scripts); + } + + /// A value of the step's with: mapping, or null. + public static string? With(YamlMappingNode step, string key) + { + return WorkflowFile.Mapping(step, "with") is { } with ? WorkflowFile.Scalar(with, key) : null; + } + + /// A value of the step's env: mapping, or null. + public static string? Env(YamlMappingNode step, string key) + { + return WorkflowFile.Mapping(step, "env") is { } env ? WorkflowFile.Scalar(env, key) : null; + } +} From 8645df8dba141c9099300b441f5841228d327ef5 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:49:32 +0200 Subject: [PATCH 050/199] Pin the bridge toolchain and prove path independence The native job built the Lua protection bridge with whatever MSVC toolset and Windows SDK the runner image defaulted to, so an image update could change the shipped DLL, and nothing recorded which toolchain produced it (audit A21-08, ADR-03, ADR-12). eng/ci/Build-NativeBridge.ps1 is now the body of the native job and runs the same way on a developer machine: - every xmake configure passes --vs_toolset/--vs_sdkver from the job-level BRIDGE_VS_TOOLSET (14.44, the VS 2022 default and a side-by-side component of the VS 2026 image) and BRIDGE_VS_SDKVER (10.0.26100.0), and --ccache=n so every build really compiles; - a primary build, a second build into another directory and a third from a byte-copy of the two fingerprinted inputs under RUNNER_TEMP must have the same SHA-256 (reproducible and path independent); xmake paths stay relative because xmake 3.0.9 mis-parses an absolute -o; - the toolset and SDK xmake actually resolved are read back and must match the pins, and the PE header of the DLL must record the linker of that toolset; - the runner image, xmake, toolset, compiler banner, SDK, the CI-built and checked-in DLL hashes and the source fingerprint go to the step summary and to job outputs, from which build-info.json is written. Byte drift from the checked-in DLL is a notice, never a failure. NativeBridgePeAuditTests keeps its method names (PR-SEQ-04) and now reads the job and the script: xmake pin and double build, the toolset and SDK pins with the resolved-toolchain check, the copied-tree rebuild, the windows-2025 label and the job outputs. Verified locally with xmake 3.0.9: all three builds give the same SHA-256 with linker 14.44; the checked-in DLL was linked by 14.51, so the first CI runs report drift. Toolset selection: https://learn.microsoft.com/cpp/build/building-on-the-command-line#use-the-developer-tools-in-an-existing-command-window --- .github/workflows/ci.yml | 76 +--- CheatEngine.SDK.slnx | 3 + eng/ci/Build-NativeBridge.ps1 | 375 ++++++++++++++++++ .../Packaging/NativeBridgePeAuditTests.cs | 151 ++++++- 4 files changed, 537 insertions(+), 68 deletions(-) create mode 100644 eng/ci/Build-NativeBridge.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff90c3cb..24f94703 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,21 @@ jobs: name: Build native bridge runs-on: windows-2025 timeout-minutes: 15 + env: + # The bridge toolchain pins. 14.44 is the default toolset of the VS 2022 image and a side-by-side component of the + # VS 2026 image, so the pin holds whichever image windows-2025 maps to. A bump changes the shipped DLL bytes: + # change both values in one reviewed commit (NativeBridgePeAuditTests reads them). + BRIDGE_VS_TOOLSET: '14.44' + BRIDGE_VS_SDKVER: '10.0.26100.0' + outputs: + image-version: ${{ steps.bridge.outputs.image-version }} + xmake-version: ${{ steps.bridge.outputs.xmake-version }} + msvc-toolset: ${{ steps.bridge.outputs.msvc-toolset }} + msvc-version: ${{ steps.bridge.outputs.msvc-version }} + windows-sdk-version: ${{ steps.bridge.outputs.windows-sdk-version }} + bridge-sha256: ${{ steps.bridge.outputs.bridge-sha256 }} + bridge-fingerprint: ${{ steps.bridge.outputs.bridge-fingerprint }} + checked-in-bridge-sha256: ${{ steps.bridge.outputs.checked-in-bridge-sha256 }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -58,62 +73,14 @@ jobs: with: xmake-version: '3.0.9' + # Primary build, a second build into another directory and a third from a copy of the two build inputs under + # RUNNER_TEMP: all three must have the same SHA-256. The step summary and the job outputs record the toolchain. - name: Build bridge + id: bridge run: | - $ErrorActionPreference = 'Stop' - # xmake 3.0.9 mis-parses an absolute Windows -o path when the target - # uses $(builddir); keep both xmake paths relative and resolve them - # only for PowerShell validation and cleanup. - $repositoryRoot = [IO.Path]::GetFullPath($PWD.Path) - $primaryOutput = 'artifacts/native/cheatengine-sdk-lua-bridge' - $reproducibilityOutput = 'artifacts/native/cheatengine-sdk-lua-bridge-repro' - $primaryOutputPath = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $primaryOutput)) - $reproducibilityOutputPath = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $reproducibilityOutput)) - if ([string]::Equals($primaryOutputPath, $reproducibilityOutputPath, [StringComparison]::OrdinalIgnoreCase)) { - throw 'The primary and reproducibility bridge output directories must be distinct.' - } - - $workspacePrefix = "$repositoryRoot$([IO.Path]::DirectorySeparatorChar)" - foreach ($resolvedOutput in @($primaryOutputPath, $reproducibilityOutputPath)) { - if (-not $resolvedOutput.StartsWith($workspacePrefix, [StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to clean native bridge output outside the checkout: '$resolvedOutput'." - } - if (Test-Path -LiteralPath $resolvedOutput) { - Remove-Item -LiteralPath $resolvedOutput -Recurse -Force - } - New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null - } - - xmake f -P native/cheatengine-sdk-lua-bridge -o $primaryOutput -p windows -a x64 -m release -y - if ($LASTEXITCODE -ne 0) { - throw "xmake configuration for the primary bridge output failed with exit code $LASTEXITCODE." - } - xmake -P native/cheatengine-sdk-lua-bridge -y + ./eng/ci/Build-NativeBridge.ps1 -VsToolset $env:BRIDGE_VS_TOOLSET -VsSdkVersion $env:BRIDGE_VS_SDKVER -PathCheckRoot (Join-Path $env:RUNNER_TEMP 'bridge-path-check') if ($LASTEXITCODE -ne 0) { - throw "xmake build for the primary bridge output failed with exit code $LASTEXITCODE." - } - $primaryBridge = Join-Path $primaryOutputPath 'cheatengine-sdk-lua-bridge.dll' - if (-not (Test-Path -LiteralPath $primaryBridge -PathType Leaf)) { - throw "xmake did not produce '$primaryBridge'." - } - - xmake f -P native/cheatengine-sdk-lua-bridge -o $reproducibilityOutput -p windows -a x64 -m release -y - if ($LASTEXITCODE -ne 0) { - throw "xmake configuration for the reproducibility bridge output failed with exit code $LASTEXITCODE." - } - xmake -P native/cheatengine-sdk-lua-bridge -y - if ($LASTEXITCODE -ne 0) { - throw "xmake build for the reproducibility bridge output failed with exit code $LASTEXITCODE." - } - $reproducibilityBridge = Join-Path $reproducibilityOutputPath 'cheatengine-sdk-lua-bridge.dll' - if (-not (Test-Path -LiteralPath $reproducibilityBridge -PathType Leaf)) { - throw "xmake did not produce '$reproducibilityBridge'." - } - - $primaryHash = (Get-FileHash -LiteralPath $primaryBridge -Algorithm SHA256).Hash - $reproducibilityHash = (Get-FileHash -LiteralPath $reproducibilityBridge -Algorithm SHA256).Hash - if (-not [string]::Equals($primaryHash, $reproducibilityHash, [StringComparison]::OrdinalIgnoreCase)) { - throw "The native bridge is not reproducible: '$primaryBridge' SHA-256 is $primaryHash but '$reproducibilityBridge' is $reproducibilityHash." + throw "The native bridge build failed with exit code $LASTEXITCODE." } - name: Upload bridge @@ -124,7 +91,8 @@ jobs: if-no-files-found: error retention-days: 14 - # Run after upload so a failed drift check still leaves the corrected DLL available to download. + # Run after upload so a failed check still leaves the corrected DLL available to download. Byte drift between the + # CI-built and the checked-in DLL is only a notice (previous step); a checked-in DLL built from other sources fails. - name: Verify checked-in bridge matches its source run: | $sourceHash = (Get-FileHash -Algorithm SHA256 native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c).Hash.ToLowerInvariant() diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 9bdfc91e..af4cc24b 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -46,6 +46,9 @@ + + + diff --git a/eng/ci/Build-NativeBridge.ps1 b/eng/ci/Build-NativeBridge.ps1 new file mode 100644 index 00000000..ae2c2224 --- /dev/null +++ b/eng/ci/Build-NativeBridge.ps1 @@ -0,0 +1,375 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Builds the native Lua protection bridge with a pinned MSVC toolset and Windows SDK, proves the build reproducible + and path independent, and records the toolchain that produced it. + +.DESCRIPTION + The bridge (native/cheatengine-sdk-lua-bridge) is a mandatory asset of the CheatEngine.SDK package (ADR-03, audit + ch.21). This script is the body of the CI `native` job and runs the same way on a developer machine that has xmake + and the pinned MSVC components. + + Three builds, each configured with --vs_toolset/--vs_sdkver (the pins) and --ccache=n (every build compiles from + source, so a cache hit cannot fake reproducibility): + 1. primary artifacts/native/cheatengine-sdk-lua-bridge the DLL later jobs consume + 2. reproducibility artifacts/native/cheatengine-sdk-lua-bridge-repro same tree, second output directory + 3. path check the two build inputs copied under -PathCheckRoot and built from there, so a source or + output path embedded in the image would change its bytes + All three DLLs must have the same SHA-256, and the primary DLL must export the source fingerprint + `:`. + + xmake 3.0.9 mis-parses an absolute Windows -o path when the target uses $(builddir), so every xmake path is + relative to the current directory: the path-check build runs from inside the copied tree. + + Facts recorded (step summary, GITHUB_OUTPUT, artifacts/native/native-toolchain.json): runner image, xmake version, + the MSVC toolset and Windows SDK that xmake actually resolved (read from xmake's toolchain cache and checked + against the pins), the compiler banner version, the linker version the PE header of the DLL records (checked + against the resolved toolset), the CI-built and checked-in DLL SHA-256 and the fingerprint. The + JSON file is a local record, not a contract document: the contract copy of these facts is build-info.json, written + from this job's outputs. A CI-built DLL whose bytes differ from the checked-in DLL is drift, reported with a + ::notice:: and never a failure; a checked-in DLL built from other sources is caught by the fingerprint check of + the workflow and by NativeBridgePeAuditTests. + +.PARAMETER VsToolset + MSVC toolset prefix passed to xmake as --vs_toolset (for example 14.44: the newest installed 14.44.x is used). + +.PARAMETER VsSdkVersion + Exact Windows SDK version passed to xmake as --vs_sdkver (for example 10.0.26100.0). + +.PARAMETER PathCheckRoot + Directory outside the repository that receives the copied build inputs. Defaults to bridge-path-check under + RUNNER_TEMP, or under the system temporary directory outside GitHub Actions. + +.PARAMETER Xmake + The xmake executable. Defaults to xmake on PATH. + +.EXAMPLE + ./eng/ci/Build-NativeBridge.ps1 -VsToolset 14.44 -VsSdkVersion 10.0.26100.0 + + Builds, compares and prints the facts table. Never commit the resulting DLL by hand: the checked-in DLL is replaced + only by the lua-protection-bridge artifact of a CI run. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+$')] + [string] $VsToolset, + + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+\.\d+\.\d+$')] + [string] $VsSdkVersion, + + [string] $PathCheckRoot = (Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) 'bridge-path-check'), + + [string] $Xmake = 'xmake' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$InformationPreference = 'Continue' + +# English compiler banners, whatever the host language. +$env:VSLANG = '1033' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$projectDirectory = 'native/cheatengine-sdk-lua-bridge' +$bridgeFileName = 'cheatengine-sdk-lua-bridge.dll' +$buildInputs = @('cheatengine_sdk_lua_bridge.c', 'xmake.lua') +$checkedInBridge = "$projectDirectory/runtimes/win-x64/native/$bridgeFileName" +$primaryOutput = 'artifacts/native/cheatengine-sdk-lua-bridge' +$reproducibilityOutput = 'artifacts/native/cheatengine-sdk-lua-bridge-repro' +$pathCheckOutput = 'artifacts/native/path-check' +$factsFile = 'artifacts/native/native-toolchain.json' +$inGitHubActions = $env:GITHUB_ACTIONS -eq 'true' + +function Get-Sha256 { + param([Parameter(Mandatory)] [string] $Path) + + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Initialize-EmptyDirectory { + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [string] $AllowedRoot + ) + + $resolved = [IO.Path]::GetFullPath($Path) + $prefix = [IO.Path]::GetFullPath($AllowedRoot).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean '$resolved', which is outside '$prefix'." + } + if (Test-Path -LiteralPath $resolved) { + Remove-Item -LiteralPath $resolved -Recurse -Force + } + New-Item -ItemType Directory -Path $resolved -Force | Out-Null +} + +# Configures and builds the bridge from the current directory into a relative output directory, and returns the DLL. +function Invoke-BridgeBuild { + param( + [Parameter(Mandatory)] [string] $OutputDirectory, + [Parameter(Mandatory)] [string] $Label + ) + + # .NET resolves relative paths against the process directory, not the PowerShell location: anchor them explicitly. + $outputPath = [IO.Path]::GetFullPath((Join-Path $PWD.Path $OutputDirectory)) + Initialize-EmptyDirectory -Path $outputPath -AllowedRoot $PWD.Path + # Out-Host keeps the build log visible without turning it into the function's return value. + & $Xmake f -P $projectDirectory -o $OutputDirectory -p windows -a x64 -m release -y --ccache=n "--vs_toolset=$VsToolset" "--vs_sdkver=$VsSdkVersion" | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "xmake configuration for the $Label bridge output failed with exit code $LASTEXITCODE." + } + & $Xmake -P $projectDirectory -y | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "xmake build for the $Label bridge output failed with exit code $LASTEXITCODE." + } + + $bridge = Join-Path $outputPath $bridgeFileName + if (-not (Test-Path -LiteralPath $bridge -PathType Leaf)) { + throw "xmake did not produce '$bridge'." + } + return $bridge +} + +function Get-ExportedFingerprint { + param([Parameter(Mandatory)] [string] $Path) + + $module = [Runtime.InteropServices.NativeLibrary]::Load($Path) + try { + $address = [Runtime.InteropServices.NativeLibrary]::GetExport($module, 'cheatengine_sdk_lua_bridge_source_fingerprint') + return [Runtime.InteropServices.Marshal]::PtrToStringAnsi($address) + } + finally { + [Runtime.InteropServices.NativeLibrary]::Free($module) + } +} + +# xmake 3.0.9 caches the environment of the vcvars call it made for the msvc toolchain; that is the toolset and SDK the +# build actually used. The format is internal to xmake, which is why the xmake version is pinned with this script. +function Get-ResolvedToolchain { + $cache = Join-Path $PWD.Path '.xmake/windows/x64/cache/toolchain' + if (-not (Test-Path -LiteralPath $cache -PathType Leaf)) { + throw "xmake did not write its toolchain cache '$cache'; update Build-NativeBridge.ps1 together with the xmake pin." + } + $text = Get-Content -LiteralPath $cache -Raw + + $facts = [ordered]@{} + foreach ($name in @('VCToolsVersion', 'WindowsSDKVersion', 'VCToolsInstallDir')) { + $values = @([regex]::Matches($text, "\b$name\s*=\s*(?:`"(?[^`"]+)`"|\[\[(?[^\]]+)\]\])") | + ForEach-Object { $_.Groups['value'].Value.Trim().TrimEnd('\') } | Sort-Object -Unique) + if ($values.Count -ne 1) { + throw "xmake's toolchain cache holds $($values.Count) values for $name ($($values -join ', ')); expected exactly one." + } + $facts[$name] = $values[0] + } + return $facts +} + +# The PE optional header records the major.minor version of the linker that produced the image: byte-level evidence of +# the toolset, independent of what xmake reports. +function Get-LinkerVersion { + param([Parameter(Mandatory)] [string] $Path) + + $stream = [IO.File]::OpenRead($Path) + try { + $reader = [Reflection.PortableExecutable.PEReader]::new($stream) + try { + $header = $reader.PEHeaders.PEHeader + return "$($header.MajorLinkerVersion).$($header.MinorLinkerVersion)" + } + finally { + $reader.Dispose() + } + } + finally { + $stream.Dispose() + } +} + +function Get-CompilerVersion { + param([Parameter(Mandatory)] [string] $ToolsInstallDirectory) + + $compiler = Join-Path $ToolsInstallDirectory 'bin/HostX64/x64/cl.exe' + if (-not (Test-Path -LiteralPath $compiler -PathType Leaf)) { + throw "The resolved toolset has no x64 compiler at '$compiler'." + } + # Without arguments cl.exe prints its banner and usage, and exits 0. + $banner = @(& $compiler 2>&1 | ForEach-Object { "$_" }) + if ($LASTEXITCODE -ne 0) { + throw "'$compiler' exited with code $LASTEXITCODE while printing its banner." + } + foreach ($line in $banner) { + if ($line -match 'C/C\+\+.*?\b(?\d+\.\d+\.\d+(?:\.\d+)?)\b') { + return $Matches['version'] + } + } + throw "Could not read the compiler version from the banner of '$compiler': $($banner -join ' | ')" +} + +function Write-GitHubFile { + param( + [Parameter(Mandatory)] [AllowEmptyString()] [string] $Path, + [Parameter(Mandatory)] [AllowEmptyString()] [string[]] $Line + ) + + if ($Path) { + $Line | Out-File -FilePath $Path -Append -Encoding utf8 + } +} + +Push-Location -LiteralPath $repositoryRoot +try { + # Record the checked-in DLL before anything could replace it. + $checkedInSha256 = Get-Sha256 -Path $checkedInBridge + $checkedInLinkerVersion = Get-LinkerVersion -Path (Join-Path $repositoryRoot $checkedInBridge) + $sourceHashes = foreach ($inputFile in $buildInputs) { Get-Sha256 -Path "$projectDirectory/$inputFile" } + $sourceFingerprint = $sourceHashes -join ':' + + $xmakeBanner = @(& $Xmake --version 2>&1 | ForEach-Object { "$_" -replace '\x1b\[[0-9;]*m', '' }) + if ($LASTEXITCODE -ne 0) { + throw "xmake --version failed with exit code $LASTEXITCODE." + } + $xmakeVersionLine = $xmakeBanner | Where-Object { $_ -match '\bxmake v\d+\.\d+\.\d+' } | Select-Object -First 1 + if (-not $xmakeVersionLine -or -not ($xmakeVersionLine -match '\bxmake v(?\d+\.\d+\.\d+)')) { + throw "Could not read the xmake version from '$($xmakeBanner -join ' | ')'." + } + $xmakeVersion = $Matches['version'] + + $primaryOutputPath = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $primaryOutput)) + $reproducibilityOutputPath = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $reproducibilityOutput)) + if ([string]::Equals($primaryOutputPath, $reproducibilityOutputPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'The primary and reproducibility bridge output directories must be distinct.' + } + + $primaryBridge = Invoke-BridgeBuild -OutputDirectory $primaryOutput -Label 'primary' + $toolchain = Get-ResolvedToolchain + $reproducibilityBridge = Invoke-BridgeBuild -OutputDirectory $reproducibilityOutput -Label 'reproducibility' + + $primaryHash = Get-Sha256 -Path $primaryBridge + $reproducibilityHash = Get-Sha256 -Path $reproducibilityBridge + if (-not [string]::Equals($primaryHash, $reproducibilityHash, [StringComparison]::OrdinalIgnoreCase)) { + throw "The native bridge is not reproducible: '$primaryBridge' SHA-256 is $primaryHash but '$reproducibilityBridge' is $reproducibilityHash." + } +} +finally { + Pop-Location +} + +# Path independence: the same two inputs, byte for byte, in a different directory at a different depth. +$resolvedPathCheckRoot = [IO.Path]::GetFullPath($PathCheckRoot, $PWD.Path) +$repositoryPrefix = $repositoryRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar +if ("$resolvedPathCheckRoot$([IO.Path]::DirectorySeparatorChar)".StartsWith($repositoryPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "The path-check root '$resolvedPathCheckRoot' must be outside the repository '$repositoryRoot'." +} +Initialize-EmptyDirectory -Path $resolvedPathCheckRoot -AllowedRoot (Split-Path -Path $resolvedPathCheckRoot -Parent) +$copiedProject = Join-Path $resolvedPathCheckRoot $projectDirectory +New-Item -ItemType Directory -Path $copiedProject -Force | Out-Null +foreach ($inputFile in $buildInputs) { + Copy-Item -LiteralPath (Join-Path $repositoryRoot "$projectDirectory/$inputFile") -Destination $copiedProject +} + +Push-Location -LiteralPath $resolvedPathCheckRoot +try { + $pathCheckBridge = Invoke-BridgeBuild -OutputDirectory $pathCheckOutput -Label 'path-check' +} +finally { + Pop-Location +} +$pathCheckHash = Get-Sha256 -Path $pathCheckBridge +if (-not [string]::Equals($primaryHash, $pathCheckHash, [StringComparison]::OrdinalIgnoreCase)) { + throw "The native bridge depends on its build path: '$primaryBridge' SHA-256 is $primaryHash but the copy built under '$resolvedPathCheckRoot' is $pathCheckHash. Remove the embedded path (for example a /pathmap: flag in xmake.lua, which changes the fingerprint and needs a CI-built DLL)." +} + +$exportedFingerprint = Get-ExportedFingerprint -Path $primaryBridge +if ($exportedFingerprint -cne $sourceFingerprint) { + throw "The CI-built bridge exports fingerprint '$exportedFingerprint', expected '$sourceFingerprint' from its build inputs." +} + +$msvcToolset = $toolchain['VCToolsVersion'] +$windowsSdk = $toolchain['WindowsSDKVersion'] +if (-not $msvcToolset.StartsWith("$VsToolset.", [StringComparison]::Ordinal)) { + throw "xmake resolved MSVC toolset $msvcToolset although --vs_toolset=$VsToolset was requested." +} +if ($windowsSdk -cne $VsSdkVersion) { + throw "xmake resolved Windows SDK $windowsSdk although --vs_sdkver=$VsSdkVersion was requested." +} +$msvcVersion = Get-CompilerVersion -ToolsInstallDirectory $toolchain['VCToolsInstallDir'] +$linkerVersion = Get-LinkerVersion -Path $primaryBridge +$expectedLinkerVersion = ($msvcToolset -split '\.')[0..1] -join '.' +if ($linkerVersion -cne $expectedLinkerVersion) { + throw "The CI-built bridge records linker $linkerVersion in its PE header, but xmake resolved MSVC toolset $msvcToolset." +} + +$imageOs = $env:ImageOS +$imageVersion = $env:ImageVersion +if ($inGitHubActions -and (-not $imageOs -or -not $imageVersion)) { + throw 'GitHub Actions did not provide ImageOS and ImageVersion; the runner image cannot be recorded.' +} +$imageOs = $imageOs ? $imageOs : 'local' +$imageVersion = $imageVersion ? $imageVersion : 'local' +$drift = $primaryHash -ne $checkedInSha256 + +$facts = [ordered]@{ + imageOs = $imageOs + imageVersion = $imageVersion + xmake = $xmakeVersion + vsToolsetPin = $VsToolset + vsSdkVersionPin = $VsSdkVersion + msvcToolset = $msvcToolset + msvcVersion = $msvcVersion + windowsSdk = $windowsSdk + bridge = [ordered]@{ + sha256 = $primaryHash + reproducibilitySha256 = $reproducibilityHash + pathCheckSha256 = $pathCheckHash + linkerVersion = $linkerVersion + checkedInSha256 = $checkedInSha256 + checkedInLinkerVersion = $checkedInLinkerVersion + sourceFingerprint = $sourceFingerprint + driftFromCheckedIn = $drift + } +} +$factsPath = Join-Path $repositoryRoot $factsFile +$facts | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $factsPath -Encoding utf8NoBOM + +Write-GitHubFile -Path ($env:GITHUB_OUTPUT ?? '') -Line @( + "image-version=$imageVersion" + "xmake-version=$xmakeVersion" + "msvc-toolset=$msvcToolset" + "msvc-version=$msvcVersion" + "windows-sdk-version=$windowsSdk" + "bridge-sha256=$primaryHash" + "bridge-fingerprint=$sourceFingerprint" + "checked-in-bridge-sha256=$checkedInSha256" +) + +$summary = @( + '### Native bridge toolchain' + '' + '| Fact | Value |' + '| --- | --- |' + "| Runner image | ``$imageOs`` ``$imageVersion`` |" + "| xmake | ``$xmakeVersion`` |" + "| MSVC toolset (pin ``$VsToolset``) | ``$msvcToolset`` |" + "| MSVC compiler | ``$msvcVersion`` |" + "| Windows SDK (pin ``$VsSdkVersion``) | ``$windowsSdk`` |" + "| CI-built bridge SHA-256 (primary = reproducibility = path check) | ``$primaryHash`` |" + "| CI-built bridge linker version (PE header) | ``$linkerVersion`` |" + "| Checked-in bridge SHA-256 | ``$checkedInSha256`` |" + "| Checked-in bridge linker version (PE header) | ``$checkedInLinkerVersion`` |" + "| Source fingerprint | ``$sourceFingerprint`` |" + "| Drift from the checked-in DLL | $(if ($drift) { 'yes (notice)' } else { 'no' }) |" +) +Write-GitHubFile -Path ($env:GITHUB_STEP_SUMMARY ?? '') -Line $summary +$summary | ForEach-Object { Write-Information $_ } + +if ($drift) { + $message = "The CI-built bridge ($primaryHash) differs from the checked-in DLL ($checkedInSha256). This is drift, not a failure: download the lua-protection-bridge artifact of this run to refresh the checked-in DLL deliberately." + if ($inGitHubActions) { + Write-Host "::notice title=Native bridge drift::$message" + } + else { + Write-Information $message + } +} diff --git a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs index 88ae1000..a3cca995 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs @@ -18,7 +18,24 @@ public sealed class NativeBridgePeAuditTests private const string SourceRelativePath = "native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c"; private const string BuildRelativePath = "native/cheatengine-sdk-lua-bridge/xmake.lua"; private const string ContinuousIntegrationWorkflowRelativePath = ".github/workflows/ci.yml"; + private const string NativeBuildScriptRelativePath = "eng/ci/Build-NativeBridge.ps1"; private const string PinnedXmakeVersion = "3.0.9"; + private const string PinnedMsvcToolset = "14.44"; + private const string PinnedWindowsSdk = "10.0.26100.0"; + private const string PinnedWindowsRunner = "windows-2025"; + + /// The job outputs build-info.json is written from (shared contract 1.6), plus the resolved toolset folder. + private static readonly string[] s_toolchainOutputs = + [ + "image-version", + "xmake-version", + "msvc-toolset", + "msvc-version", + "windows-sdk-version", + "bridge-sha256", + "bridge-fingerprint", + "checked-in-bridge-sha256" + ]; private static readonly string[] ExpectedExports = [ @@ -105,27 +122,108 @@ public void Native_bridge_xmake_configuration_pins_the_required_compilation_cont [Fact] public void Native_bridge_ci_pins_xmake_and_enforces_a_double_build_reproducibility_gate() { - string workflow = ReadRepositoryText(ContinuousIntegrationWorkflowRelativePath); + string job = ReadNativeJob(); + string script = ReadRepositoryText(NativeBuildScriptRelativePath); - Assert.Contains("xmake-io/github-action-setup-xmake@", workflow, StringComparison.Ordinal); - Assert.Contains($"xmake-version: '{PinnedXmakeVersion}'", workflow, StringComparison.Ordinal); - Assert.Contains("$primaryOutput = 'artifacts/native/cheatengine-sdk-lua-bridge'", workflow, + // The native job installs the pinned xmake and delegates the builds to the script, which runs the same locally. + Assert.Contains("xmake-io/github-action-setup-xmake@", job, StringComparison.Ordinal); + Assert.Contains($"xmake-version: '{PinnedXmakeVersion}'", job, StringComparison.Ordinal); + Assert.Contains( + "./eng/ci/Build-NativeBridge.ps1 -VsToolset $env:BRIDGE_VS_TOOLSET -VsSdkVersion $env:BRIDGE_VS_SDKVER", + job, StringComparison.Ordinal); + Assert.Contains("path: artifacts/native/cheatengine-sdk-lua-bridge/cheatengine-sdk-lua-bridge.dll", job, StringComparison.Ordinal); - Assert.Contains("$reproducibilityOutput = 'artifacts/native/cheatengine-sdk-lua-bridge-repro'", workflow, + + Assert.Contains("$projectDirectory = 'native/cheatengine-sdk-lua-bridge'", script, StringComparison.Ordinal); + Assert.Contains("$primaryOutput = 'artifacts/native/cheatengine-sdk-lua-bridge'", script, StringComparison.Ordinal); - Assert.Contains("The primary and reproducibility bridge output directories must be distinct.", workflow, + Assert.Contains("$reproducibilityOutput = 'artifacts/native/cheatengine-sdk-lua-bridge-repro'", script, StringComparison.Ordinal); - Assert.Contains("xmake f -P native/cheatengine-sdk-lua-bridge -o $primaryOutput", workflow, + Assert.Contains("The primary and reproducibility bridge output directories must be distinct.", script, StringComparison.Ordinal); - Assert.Contains("xmake f -P native/cheatengine-sdk-lua-bridge -o $reproducibilityOutput", workflow, + Assert.Contains("& $Xmake f -P $projectDirectory -o $OutputDirectory", script, StringComparison.Ordinal); + Assert.Contains("$primaryBridge = Invoke-BridgeBuild -OutputDirectory $primaryOutput", script, StringComparison.Ordinal); - Assert.Contains("$primaryHash = (Get-FileHash -LiteralPath $primaryBridge -Algorithm SHA256).Hash", workflow, + Assert.Contains("$reproducibilityBridge = Invoke-BridgeBuild -OutputDirectory $reproducibilityOutput", script, StringComparison.Ordinal); - Assert.Contains( - "$reproducibilityHash = (Get-FileHash -LiteralPath $reproducibilityBridge -Algorithm SHA256).Hash", - workflow, StringComparison.Ordinal); - Assert.Contains("$primaryHash, $reproducibilityHash, [StringComparison]::OrdinalIgnoreCase", workflow, + Assert.Contains("$primaryHash = Get-Sha256 -Path $primaryBridge", script, StringComparison.Ordinal); + Assert.Contains("$reproducibilityHash = Get-Sha256 -Path $reproducibilityBridge", script, + StringComparison.Ordinal); + Assert.Contains("$primaryHash, $reproducibilityHash, [StringComparison]::OrdinalIgnoreCase", script, + StringComparison.Ordinal); + // Every build compiles from source: a compiler-cache hit must not stand in for a second compilation. + Assert.Contains("--ccache=n", script, StringComparison.Ordinal); + } + + [Fact] + public void Native_bridge_ci_pins_the_msvc_toolset_and_windows_sdk() + { + string job = ReadNativeJob(); + string script = ReadRepositoryText(NativeBuildScriptRelativePath); + + Assert.Contains($"BRIDGE_VS_TOOLSET: '{PinnedMsvcToolset}'", job, StringComparison.Ordinal); + Assert.Contains($"BRIDGE_VS_SDKVER: '{PinnedWindowsSdk}'", job, StringComparison.Ordinal); + + // The one xmake configure line of the script passes both pins, for the primary, reproducibility and path builds. + Assert.Single(script.Split('\n'), static line => line.Contains("& $Xmake f ", StringComparison.Ordinal)); + Assert.Contains("\"--vs_toolset=$VsToolset\" \"--vs_sdkver=$VsSdkVersion\"", script, StringComparison.Ordinal); + + // A pin xmake silently ignored would still build: the script compares what xmake resolved with the pins. + Assert.Contains("xmake resolved MSVC toolset $msvcToolset although --vs_toolset=$VsToolset was requested.", + script, StringComparison.Ordinal); + Assert.Contains("xmake resolved Windows SDK $windowsSdk although --vs_sdkver=$VsSdkVersion was requested.", + script, StringComparison.Ordinal); + // The bytes agree: the PE header of the CI-built DLL records the linker of the resolved toolset. + Assert.Contains("$linkerVersion = Get-LinkerVersion -Path $primaryBridge", script, StringComparison.Ordinal); + Assert.Contains("records linker $linkerVersion in its PE header, but xmake resolved MSVC toolset $msvcToolset.", script, + StringComparison.Ordinal); + } + + [Fact] + public void Native_bridge_ci_rebuilds_from_a_copied_tree_and_compares_hashes() + { + string job = ReadNativeJob(); + string script = ReadRepositoryText(NativeBuildScriptRelativePath); + + Assert.Contains("-PathCheckRoot (Join-Path $env:RUNNER_TEMP 'bridge-path-check')", job, StringComparison.Ordinal); + + // Only the two fingerprinted build inputs are copied, byte for byte, and built from inside the copy with a + // relative output path (xmake 3.0.9 mis-parses an absolute -o). + Assert.Contains("$buildInputs = @('cheatengine_sdk_lua_bridge.c', 'xmake.lua')", script, StringComparison.Ordinal); + Assert.Contains("$pathCheckOutput = 'artifacts/native/path-check'", script, StringComparison.Ordinal); + Assert.Contains("Copy-Item -LiteralPath (Join-Path $repositoryRoot \"$projectDirectory/$inputFile\")", script, + StringComparison.Ordinal); + Assert.Contains("Push-Location -LiteralPath $resolvedPathCheckRoot", script, StringComparison.Ordinal); + Assert.Contains("$pathCheckBridge = Invoke-BridgeBuild -OutputDirectory $pathCheckOutput", script, + StringComparison.Ordinal); + Assert.Contains("[string]::Equals($primaryHash, $pathCheckHash, [StringComparison]::OrdinalIgnoreCase)", script, StringComparison.Ordinal); + Assert.Contains("must be outside the repository", script, StringComparison.Ordinal); + } + + [Fact] + public void Native_bridge_ci_runs_on_the_pinned_windows_label() + { + string job = ReadNativeJob(); + + Assert.Contains($"runs-on: {PinnedWindowsRunner}\n", job, StringComparison.Ordinal); + Assert.DoesNotContain("-latest", job, StringComparison.Ordinal); + } + + [Fact] + public void Native_bridge_ci_publishes_the_toolchain_facts_as_job_outputs() + { + string job = ReadNativeJob(); + string script = ReadRepositoryText(NativeBuildScriptRelativePath); + + foreach (string output in s_toolchainOutputs) + { + Assert.Contains($"{output}: ${{{{ steps.bridge.outputs.{output} }}}}", job, StringComparison.Ordinal); + Assert.Contains($"\"{output}=", script, StringComparison.Ordinal); + } + + // Byte drift from the checked-in DLL is a notice, never a failure (shared contract 1.6). + Assert.Contains("::notice title=Native bridge drift::", script, StringComparison.Ordinal); } private static PortableExecutableInspector ReadBridge() @@ -156,9 +254,34 @@ private static void AssertExactSet(string[] expected, List actual) } } + /// Reads a committed text file with LF line endings, whatever the checkout's line-ending conversion. private static string ReadRepositoryText(string relativePath) { - return File.ReadAllText(RepositoryLayout.PathOf(relativePath)); + return File.ReadAllText(RepositoryLayout.PathOf(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + } + + /// + /// The native job of ci.yml: from its key to the next line indented like a job key (the next job, or the + /// comment that introduces it), so an assertion cannot be satisfied by text of another job. + /// + private static string ReadNativeJob() + { + string[] lines = ReadRepositoryText(ContinuousIntegrationWorkflowRelativePath).Split('\n'); + int start = Array.IndexOf(lines, " native:"); + Assert.True(start >= 0, $"{ContinuousIntegrationWorkflowRelativePath} has no 'native' job."); + + int end = start + 1; + while (end < lines.Length && !IsJobLevelLine(lines[end])) + { + end++; + } + + return string.Join('\n', lines, start, end - start) + "\n"; + } + + private static bool IsJobLevelLine(string line) + { + return line.Length > 2 && line.StartsWith(" ", StringComparison.Ordinal) && line[2] != ' '; } private static string CalculateSha256(string path) From 471920254f6b28809d4947cdfc425797734679f5 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:50:16 +0200 Subject: [PATCH 051/199] Pack before testing and harden the test run The Release leg packed after its tests, while the packaging fixture packed its own copy: the tested nupkg was not the shipped one (audit A21-09, Q40). Nothing proved that every test module ran, a hang would burn the job timeout without a dump, and failed builds left no binary log. build-test now: - builds the solution once per leg with a binary log; - in Release, packs before testing and checks the result with eng/ci/Test-SdkPackage.ps1 (exactly one CheatEngine.SDK..nupkg, the exact name when package-version is set, nuspec identity, embedded SPDX SBOM, the bridge the native job built), then hands that exact file to the packaging tests through CESDK_PACKAGED_UMBRELLA_NUPKG, logs its SHA-256 in the step summary and verifies it is unchanged before upload; - in Debug, excludes the packaging tests with the xUnit v3 filter --filter-not-trait Category=Packaging, never with a skip, and keeps --fail-skips on in both legs; - passes --hangdump --hangdump-timeout 15m --crashdump to every module (eng/Tests.props references both extensions, otherwise a module exits with code 5) and uploads dumps and binary logs on failure only; - checks with eng/ci/Test-TestModuleInventory.ps1 that every tests/**/*.Tests.csproj produced its TRX report, ran at least one test and, in Debug, wrote its coverage report, and prints per-module counts; - compiles the CE 7.7 live probe in the Release leg (C0 only, never loaded, run or uploaded) until the probe joins the solution; the contract test then requires the step's removal. The aot job keeps binary logs of its three publications on failure. Tests freeze the step order, the exact-package hand-off, the trait filter, the MTP options and their extension packages, the hang-dump margin, the inventory, the reserved artifact names and retentions, the binlog rules, full history for versioned jobs, the Native AOT probes and the live-probe compile. MTP diagnostics: https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-extensions-diagnostics Exit code 5 with mixed extensions: https://learn.microsoft.com/dotnet/core/testing/unit-testing-with-dotnet-test#solutions-with-mixed-test-frameworks-or-extensions Binary logs: https://learn.microsoft.com/visualstudio/ide/msbuild-logs#provide-msbuild-binary-logs-for-investigation --- .github/workflows/ci.yml | 141 ++++-- CheatEngine.SDK.slnx | 2 + eng/Tests.props | 3 + eng/ci/Test-SdkPackage.ps1 | 133 ++++++ eng/ci/Test-TestModuleInventory.ps1 | 147 ++++++ .../WorkflowContractTests.BuildTest.cs | 431 ++++++++++++++++++ 6 files changed, 825 insertions(+), 32 deletions(-) create mode 100644 eng/ci/Test-SdkPackage.ps1 create mode 100644 eng/ci/Test-TestModuleInventory.ps1 create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.BuildTest.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24f94703..689a38f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,13 +146,15 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 # MinVer and the packaging tests need tags and full history + fetch-depth: 0 # MinVer computes the package version from tags and history persist-credentials: false - name: Setup .NET uses: ./.github/actions/setup-dotnet with: - restore: CheatEngine.SDK.slnx + restore: | + CheatEngine.SDK.slnx + tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj - name: Use CI-built native bridge uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -167,58 +169,101 @@ jobs: name: classic-abi-fixture-facts path: artifacts/native-abi-fixture + # One build of the whole solution, which also compiles every harness it lists (LivePlugin, Coexistence, probes). - name: Build run: | - dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore + dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore "-bl:artifacts/logs/build-test-$env:CONFIGURATION.binlog" if ($LASTEXITCODE -ne 0) { throw "$env:CONFIGURATION solution build failed with exit code $LASTEXITCODE." } - # One parallel run over every tests/**/*.Tests project of the solution, which also proves that the solution - # discovers every test module. A skip fails both configurations: NativeLua tests skip when the bundled Lua DLL - # does not bind, and a green check must not hide that. In Debug, the facts the native job built make the managed - # ABI comparison mandatory, and every module writes its own GUID-named coverage report. + # C0 evidence only: the CE 7.7 live probe must keep compiling, but CI never loads or runs it and uploads nothing it + # produces. This step exists until the probe joins CheatEngine.SDK.slnx; WorkflowContractTests then requires its + # removal, because the solution build compiles the probe in both legs. + - name: Compile live probe + if: matrix.configuration == 'Release' + run: | + dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Release --no-restore -bl:artifacts/logs/build-test-live-probe.binlog + if ($LASTEXITCODE -ne 0) { + throw "The live probe no longer compiles (exit code $LASTEXITCODE)." + } + + # Pack before testing: the packaging tests below consume this exact file, which is then uploaded unchanged. The + # pack is incremental after the build and recompiles nothing. + - name: Pack + id: pack + if: matrix.configuration == 'Release' + env: + PACKAGE_VERSION: ${{ inputs.package-version }} + BRIDGE_SHA256: ${{ needs.native.outputs.bridge-sha256 }} + run: | + dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog + if ($LASTEXITCODE -ne 0) { + throw "SDK package creation failed with exit code $LASTEXITCODE." + } + ./eng/ci/Test-SdkPackage.ps1 -PackageDirectory artifacts/nuget -PackageVersion $env:PACKAGE_VERSION -ExpectedBridgeSha256 $env:BRIDGE_SHA256 + if ($LASTEXITCODE -ne 0) { + throw "The packed SDK failed its checks with exit code $LASTEXITCODE." + } + + # One run over every tests/**/*.Tests module of the solution; MTP options go straight to dotnet test (SDK 10). + # A skip fails the run (--fail-skips on): NativeLua tests skip when the bundled Lua DLL does not bind, and a green + # check must not hide that. Hang and crash dumps need the extensions every module gets from eng/Tests.props. + # Debug: the native job's ABI facts make the managed ABI comparison mandatory, every module writes a coverage + # report, and the packaging tests are filtered out by trait (never skipped): they run in the Release leg against + # the packed file (CESDK_PACKAGED_UMBRELLA_NUPKG). - name: Test + id: test + env: + CESDK_PACKAGED_UMBRELLA_NUPKG: ${{ matrix.configuration == 'Release' && steps.pack.outputs.nupkg || '' }} + PACKED_NUPKG_SHA256: ${{ steps.pack.outputs.sha256 }} run: | + $ErrorActionPreference = 'Stop' $options = @( '--solution', 'CheatEngine.SDK.slnx', '-c', $env:CONFIGURATION, '--no-build', '--results-directory', $env:RESULTS, - '--fail-skips', 'on', '--report-trx', '--report-gh', '--report-gh-groups', 'off' + '--fail-skips', 'on', '--report-trx', '--report-gh', '--report-gh-groups', 'off', + '--hangdump', '--hangdump-timeout', '15m', '--crashdump' ) if ($env:CONFIGURATION -eq 'Debug') { $env:CE77_NATIVE_ABI_FACTS_PATH = Join-Path $env:GITHUB_WORKSPACE 'artifacts/native-abi-fixture/ce77-native-abi-facts.txt' $env:CE77_NATIVE_ABI_REQUIRED = 'true' - $options += '--coverage', '--coverage-output-format', 'xml' + $options += '--coverage', '--coverage-output-format', 'xml', '--filter-not-trait', 'Category=Packaging' + } + else { + $consumed = (Get-FileHash -LiteralPath $env:CESDK_PACKAGED_UMBRELLA_NUPKG -Algorithm SHA256).Hash.ToLowerInvariant() + if ($consumed -ne $env:PACKED_NUPKG_SHA256) { + throw "The package changed between Pack ($env:PACKED_NUPKG_SHA256) and Test ($consumed)." + } + "Packaging tests consume ``$(Split-Path -Leaf $env:CESDK_PACKAGED_UMBRELLA_NUPKG)`` (SHA-256 ``$consumed``)." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 } dotnet test @options if ($LASTEXITCODE -ne 0) { throw "$env:CONFIGURATION tests failed with exit code $LASTEXITCODE." } - if ($env:CONFIGURATION -eq 'Debug') { - $modules = @(Get-ChildItem -LiteralPath $env:RESULTS -Filter *.trx -File).Count - $reports = @(Get-ChildItem -LiteralPath $env:RESULTS -Filter *.xml -File).Count - if ($reports -eq 0 -or $reports -ne $modules) { - throw "Expected one coverage report per test module ($modules), found $reports." - } + + # Every tests/**/*.Tests.csproj must have produced its report: a dropped or silently empty module fails here. + - name: Check test module inventory + if: ${{ !cancelled() && steps.test.outcome != 'skipped' }} + run: | + $options = @{ ResultsDirectory = $env:RESULTS; Configuration = $env:CONFIGURATION } + if ($env:CONFIGURATION -eq 'Debug') { $options.RequireCoverage = $true } + ./eng/ci/Test-TestModuleInventory.ps1 @options + if ($LASTEXITCODE -ne 0) { + throw "The test module inventory failed with exit code $LASTEXITCODE." } - # The shipped package comes from the build the tests just ran against. The pack is incremental: nothing recompiles. - - name: Pack + # The uploaded file is the file the tests consumed. + - name: Verify the tested package is unchanged if: matrix.configuration == 'Release' env: - PACKAGE_VERSION: ${{ inputs.package-version }} + PACKAGE: ${{ steps.pack.outputs.nupkg }} + PACKED_NUPKG_SHA256: ${{ steps.pack.outputs.sha256 }} run: | - dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget - if ($LASTEXITCODE -ne 0) { - throw "SDK package creation failed with exit code $LASTEXITCODE." - } - $packages = @(Get-ChildItem -Path artifacts/nuget -Filter *.nupkg -File) - if ($packages.Count -ne 1) { - throw "Expected one package in artifacts/nuget, found $($packages.Count)." + $actual = (Get-FileHash -LiteralPath $env:PACKAGE -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:PACKED_NUPKG_SHA256) { + throw "The package changed during the tests: packed $env:PACKED_NUPKG_SHA256, now $actual." } - if ($env:PACKAGE_VERSION -and $packages[0].Name -ne "CheatEngine.SDK.$env:PACKAGE_VERSION.nupkg") { - throw "Packed $($packages[0].Name), but the release requires CheatEngine.SDK.$env:PACKAGE_VERSION.nupkg." - } - "Packed ``$($packages[0].Name)``." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - name: Upload package if: matrix.configuration == 'Release' @@ -247,6 +292,29 @@ jobs: if-no-files-found: warn retention-days: 7 + - name: Upload test dumps + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-dumps-${{ matrix.configuration }} + # Hang and crash dumps, and the crash sequence file listing the tests that were running. + path: | + ${{ env.RESULTS }}/**/*.dmp + ${{ env.RESULTS }}/**/*sequence* + if-no-files-found: ignore + retention-days: 5 + + # Binary logs only on failure: they record the environment the build saw. + # https://learn.microsoft.com/visualstudio/ide/msbuild-logs#provide-msbuild-binary-logs-for-investigation + - name: Upload binary logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binlogs-build-test-${{ matrix.configuration }} + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + aot: name: Native AOT publication probe needs: native @@ -279,7 +347,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' $output = Join-Path $PWD 'artifacts/aot-probe' - dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore -o $output + dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore -o $output -bl:artifacts/logs/aot-probe.binlog if ($LASTEXITCODE -ne 0) { throw "Native AOT probe publish failed with exit code $LASTEXITCODE." } @@ -299,11 +367,11 @@ jobs: $profileOutput = Join-Path $PWD 'artifacts/nativeaot-loader-profile' $libraryProject = 'tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj' $harnessProject = 'tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj' - dotnet publish $libraryProject -c Release --no-restore -o $profileOutput + dotnet publish $libraryProject -c Release --no-restore -o $profileOutput -bl:artifacts/logs/aot-library-probe.binlog if ($LASTEXITCODE -ne 0) { throw "Native AOT shared-library publish failed with exit code $LASTEXITCODE." } - dotnet publish $harnessProject -c Release --no-restore -o $profileOutput + dotnet publish $harnessProject -c Release --no-restore -o $profileOutput -bl:artifacts/logs/aot-loader-harness.binlog if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness publish failed with exit code $LASTEXITCODE." } @@ -320,6 +388,15 @@ jobs: & $harness --load --acknowledge-process-resident-load if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness load exited with code $LASTEXITCODE." } + - name: Upload binary logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binlogs-aot + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + # Secrets never reach fork or Dependabot runs, and a merge-queue branch is analysed again once it lands on main. # Pull requests fail on the quality gate; main only reports it. sonar: diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index af4cc24b..993a3848 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -48,6 +48,8 @@ + + diff --git a/eng/Tests.props b/eng/Tests.props index b962f32b..2037556c 100644 --- a/eng/Tests.props +++ b/eng/Tests.props @@ -23,6 +23,9 @@ + + + diff --git a/eng/ci/Test-SdkPackage.ps1 b/eng/ci/Test-SdkPackage.ps1 new file mode 100644 index 00000000..82389aeb --- /dev/null +++ b/eng/ci/Test-SdkPackage.ps1 @@ -0,0 +1,133 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Checks the CheatEngine.SDK package the Release leg just packed, before any test consumes it. + +.DESCRIPTION + The Release leg packs before it tests, and the packaging tests consume this exact file + (CESDK_PACKAGED_UMBRELLA_NUPKG), so the file that is tested is the file that is uploaded and released. This script + asserts, in order: + - the package directory holds exactly one file, CheatEngine.SDK..nupkg, and nothing else; + - with -PackageVersion, the file name is exactly CheatEngine.SDK..nupkg; + - the nuspec id is CheatEngine.SDK and its version matches the file name; + - the SPDX SBOM is embedded at _manifest/spdx_2.2/manifest.spdx.json; + - with -ExpectedBridgeSha256, build/native/cheatengine-sdk-lua-bridge.dll is the bridge the native job built. + It then writes the file name and its SHA-256 to the step summary, and `nupkg=` and + `sha256=` to GITHUB_OUTPUT when running in GitHub Actions. + +.PARAMETER PackageDirectory + The pack output directory (dotnet pack -o). + +.PARAMETER PackageVersion + When set (tag releases), the only accepted package version. + +.PARAMETER ExpectedBridgeSha256 + When set, the SHA-256 the packed native bridge must have (the native job's bridge-sha256 output). + +.EXAMPLE + ./eng/ci/Test-SdkPackage.ps1 -PackageDirectory artifacts/nuget +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PackageDirectory, + + [AllowEmptyString()] + [string] $PackageVersion = '', + + [AllowEmptyString()] + [string] $ExpectedBridgeSha256 = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$InformationPreference = 'Continue' + +$packageId = 'CheatEngine.SDK' +$sbomEntry = '_manifest/spdx_2.2/manifest.spdx.json' +$bridgeEntry = 'build/native/cheatengine-sdk-lua-bridge.dll' + +$directory = [IO.Path]::GetFullPath($PackageDirectory, $PWD.Path) +if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + throw "The package directory '$directory' does not exist." +} + +$files = @(Get-ChildItem -LiteralPath $directory -Recurse -File) +if ($files.Count -ne 1 -or $files[0].Name -notmatch "^$([regex]::Escape($packageId))\.(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.nupkg$") { + throw "Expected exactly one $packageId..nupkg in '$directory', found: $(($files | ForEach-Object Name) -join ', ')." +} +$package = $files[0] +$fileVersion = $Matches['version'] +if ($PackageVersion -and $package.Name -cne "$packageId.$PackageVersion.nupkg") { + throw "Packed $($package.Name), but the release requires $packageId.$PackageVersion.nupkg." +} + +$stream = [IO.File]::OpenRead($package.FullName) +try { + $archive = [IO.Compression.ZipArchive]::new($stream, [IO.Compression.ZipArchiveMode]::Read) + try { + $nuspecEntry = $archive.GetEntry("$packageId.nuspec") + if ($null -eq $nuspecEntry) { + throw "$($package.Name) has no $packageId.nuspec at its root." + } + $reader = [IO.StreamReader]::new($nuspecEntry.Open()) + try { + [xml] $nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + $nuspecId = $nuspec.package.metadata.id + $nuspecVersion = $nuspec.package.metadata.version + if ($nuspecId -cne $packageId -or $nuspecVersion -cne $fileVersion) { + throw "$($package.Name) declares id '$nuspecId' version '$nuspecVersion' in its nuspec." + } + + if ($null -eq $archive.GetEntry($sbomEntry)) { + throw "$($package.Name) does not embed the SPDX SBOM at $sbomEntry (Microsoft.Sbom.Targets, CESDK9008)." + } + + $bridge = $archive.GetEntry($bridgeEntry) + if ($null -eq $bridge) { + throw "$($package.Name) does not carry the native bridge at $bridgeEntry." + } + if ($ExpectedBridgeSha256) { + $bridgeStream = $bridge.Open() + try { + $bridgeSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bridgeStream)).ToLowerInvariant() + } + finally { + $bridgeStream.Dispose() + } + if ($bridgeSha256 -cne $ExpectedBridgeSha256.ToLowerInvariant()) { + throw "$($package.Name) packs a bridge with SHA-256 $bridgeSha256, but the native job built $ExpectedBridgeSha256." + } + } + } + finally { + $archive.Dispose() + } +} +finally { + $stream.Dispose() +} + +$sha256 = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + +if ($env:GITHUB_OUTPUT) { + @("nupkg=$($package.FullName)", "sha256=$sha256", "version=$fileVersion") | + Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 +} +$summary = @( + '### Package' + '' + '| File | Version | SHA-256 |' + '| --- | --- | --- |' + "| ``$($package.Name)`` | ``$fileVersion`` | ``$sha256`` |" + '' + "The packaging tests of this leg consume this exact file, and the ``nuget-package`` artifact uploads it unchanged." +) +if ($env:GITHUB_STEP_SUMMARY) { + $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +} +Write-Information "Checked $($package.Name) (SHA-256 $sha256): one package, nuspec identity, SBOM and native bridge present." diff --git a/eng/ci/Test-TestModuleInventory.ps1 b/eng/ci/Test-TestModuleInventory.ps1 new file mode 100644 index 00000000..06ce6019 --- /dev/null +++ b/eng/ci/Test-TestModuleInventory.ps1 @@ -0,0 +1,147 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Proves that every test module of the repository ran and produced its reports. + +.DESCRIPTION + `dotnet test --solution` only runs the modules the solution lists, and a module that is dropped, renamed or fails + to start leaves no failing test behind. This script compares two sets: + - expected: the base names of every tests/**/*.Tests.csproj known to git (tracked, plus untracked non-ignored + files so a new module is checked before its first commit); + - produced: the part of every deterministic TRX name __.trx at the top of the + results directory (Microsoft.Testing.Extensions.TrxReport 2.3.0 or later). + They must be equal, and every module must have executed at least one test. With -RequireCoverage, the directory + must also hold exactly one coverage XML file per module. + + It then prints, and appends to the GitHub step summary, one row per module with its passed, failed and skipped + counts, read from the TRX ResultSummary counters. + +.PARAMETER ResultsDirectory + The --results-directory of the dotnet test run. + +.PARAMETER Configuration + Debug or Release; used in the summary title. + +.PARAMETER RequireCoverage + Also require one coverage report (*.xml) per module (the Debug leg). + +.EXAMPLE + ./eng/ci/Test-TestModuleInventory.ps1 -ResultsDirectory artifacts/test-results/Debug -Configuration Debug -RequireCoverage +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $ResultsDirectory, + + [Parameter(Mandatory)] + [ValidateSet('Debug', 'Release')] + [string] $Configuration, + + [switch] $RequireCoverage +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$InformationPreference = 'Continue' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$trxName = '^(?.+)_(?net\d+\.\d+)_(?x64|x86|arm64)\.trx$' + +function Get-GitFile { + param([Parameter(Mandatory)] [string[]] $Arguments) + + $files = @(& git -C $repositoryRoot ls-files @Arguments) + if ($LASTEXITCODE -ne 0) { + throw "git ls-files $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } + return $files +} + +# In git pathspecs '*' also matches '/', so this finds the test projects at any depth under tests/. +$projects = @(Get-GitFile -Arguments @('--', 'tests/*.Tests.csproj')) + + @(Get-GitFile -Arguments @('--others', '--exclude-standard', '--', 'tests/*.Tests.csproj')) +$expected = [Collections.Generic.SortedSet[string]]::new([StringComparer]::Ordinal) +foreach ($project in $projects) { + [void] $expected.Add([IO.Path]::GetFileNameWithoutExtension($project)) +} +if ($expected.Count -eq 0) { + throw "No tests/**/*.Tests.csproj found under '$repositoryRoot'." +} + +$directory = [IO.Path]::GetFullPath($ResultsDirectory, $PWD.Path) +if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + throw "The results directory '$directory' does not exist: the test run produced nothing." +} + +$produced = [Collections.Generic.SortedDictionary[string, IO.FileInfo]]::new([StringComparer]::Ordinal) +$unrecognized = [Collections.Generic.List[string]]::new() +foreach ($file in @(Get-ChildItem -LiteralPath $directory -Filter '*.trx' -File)) { + if ($file.Name -notmatch $trxName) { + $unrecognized.Add($file.Name) + continue + } + $module = $Matches['module'] + if ($produced.ContainsKey($module)) { + throw "Module $module produced more than one TRX file: $($produced[$module].Name) and $($file.Name)." + } + $produced.Add($module, $file) +} + +$rows = [Collections.Generic.List[string]]::new() +$problems = [Collections.Generic.List[string]]::new() +$missing = @($expected | Where-Object { -not $produced.ContainsKey($_) }) +$unexpected = @($produced.Keys | Where-Object { -not $expected.Contains($_) }) +if ($missing.Count -gt 0) { + $problems.Add("No test report for: $($missing -join ', '). Each tests/**/*.Tests.csproj must be in CheatEngine.SDK.slnx and run.") +} +if ($unexpected.Count -gt 0) { + $problems.Add("Test reports without a tests/**/*.Tests.csproj: $($unexpected -join ', ').") +} +if ($unrecognized.Count -gt 0) { + $problems.Add("TRX files without the deterministic __.trx name: $($unrecognized -join ', ').") +} + +$totals = @{ passed = 0; failed = 0; skipped = 0 } +foreach ($module in $produced.Keys) { + [xml] $trx = [IO.File]::ReadAllText($produced[$module].FullName) + $counters = $trx.TestRun.ResultSummary.Counters + $executed = [int] $counters.executed + $passed = [int] $counters.passed + $failed = [int] $counters.failed + [int] $counters.error + [int] $counters.timeout + [int] $counters.aborted + $skipped = [int] $counters.notExecuted + $totals.passed += $passed + $totals.failed += $failed + $totals.skipped += $skipped + if ($executed -eq 0) { + $problems.Add("$module executed no test.") + } + $rows.Add("| $module | $passed | $failed | $skipped |") +} + +if ($RequireCoverage) { + $coverage = @(Get-ChildItem -LiteralPath $directory -Filter '*.xml' -File) + if ($coverage.Count -ne $produced.Count) { + $problems.Add("Expected one coverage report per test module ($($produced.Count)), found $($coverage.Count).") + } +} + +$summary = @( + "### Test modules ($Configuration)" + '' + "$($produced.Count) of $($expected.Count) expected modules reported." + '' + '| Module | Passed | Failed | Skipped |' + '| --- | ---: | ---: | ---: |' +) + $rows + @("| **Total** | **$($totals.passed)** | **$($totals.failed)** | **$($totals.skipped)** |") +if ($env:GITHUB_STEP_SUMMARY) { + $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +} +$summary | ForEach-Object { Write-Information $_ } + +if ($problems.Count -gt 0) { + foreach ($problem in $problems) { + Write-Host "::error title=Test module inventory::$problem" + } + throw "The test module inventory failed: $($problems -join ' ')" +} +Write-Information "All $($expected.Count) test modules ran." diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.BuildTest.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.BuildTest.cs new file mode 100644 index 00000000..6b677f10 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.BuildTest.cs @@ -0,0 +1,431 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// The build-test and aot jobs of ci.yml (contract 1.8): one build, Pack before Test in Release, the exact package +/// handed to the packaging tests, the Debug trait filter, dumps, the module inventory, binary logs and artifacts. +/// +public sealed partial class WorkflowContractTests +{ + private const string BuildTestJob = "build-test"; + + /// The MTP options of the Test step and the Microsoft.Testing.Extensions package that provides each one. + private static readonly Dictionary s_testOptionExtensions = new(StringComparer.Ordinal) + { + ["--report-trx"] = "Microsoft.Testing.Extensions.TrxReport", + ["--report-gh"] = "Microsoft.Testing.Extensions.GitHubActionsReport", + ["--hangdump"] = "Microsoft.Testing.Extensions.HangDump", + ["--crashdump"] = "Microsoft.Testing.Extensions.CrashDump", + ["--coverage"] = "Microsoft.Testing.Extensions.CodeCoverage" + }; + + [Fact] + public void Build_test_runs_both_configurations_without_fail_fast() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + YamlMappingNode strategy = Assert.IsType(WorkflowFile.Mapping(job.Node, "strategy")); + Assert.Equal("false", WorkflowFile.Scalar(strategy, "fail-fast")); + YamlMappingNode matrix = Assert.IsType(WorkflowFile.Mapping(strategy, "matrix")); + Assert.Equal(["Debug", "Release"], WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(matrix, "configuration")))); + Assert.Equal(["native"], job.Needs()); + + // One build of the whole solution per leg, logged for failure analysis; every later step reuses it. + string build = WorkflowFile.Scalar(job.Step("Build"), "run") ?? ""; + Assert.Contains("dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore", build, StringComparison.Ordinal); + Assert.Contains("\"-bl:artifacts/logs/build-test-$env:CONFIGURATION.binlog\"", build, StringComparison.Ordinal); + } + + [Fact] + public void Release_leg_packs_before_testing_and_exports_the_exact_nupkg() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + int build = job.StepIndex("Build"); + int pack = job.StepIndex("Pack"); + int test = job.StepIndex("Test"); + int verify = job.StepIndex("Verify the tested package is unchanged"); + int upload = job.StepIndex("Upload package"); + Assert.True(build >= 0 && build < pack && pack < test && test < verify && verify < upload, + "build-test must run Build, Pack, Test, then verify and upload the tested package, in that order."); + + YamlMappingNode packStep = job.Steps[pack]; + Assert.Equal("pack", WorkflowFile.Scalar(packStep, "id")); + Assert.Equal("matrix.configuration == 'Release'", WorkflowFile.Scalar(packStep, "if")); + Assert.Equal("${{ inputs.package-version }}", WorkflowJob.Env(packStep, "PACKAGE_VERSION")); + string packRun = WorkflowFile.Scalar(packStep, "run") ?? ""; + Assert.Contains("dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog", + packRun, StringComparison.Ordinal); + Assert.Contains("./eng/ci/Test-SdkPackage.ps1 -PackageDirectory artifacts/nuget -PackageVersion $env:PACKAGE_VERSION", packRun, + StringComparison.Ordinal); + + // The packaging tests consume the packed file itself, in the Release leg only, and log its SHA-256. + YamlMappingNode testStep = job.Steps[test]; + Assert.Equal("${{ matrix.configuration == 'Release' && steps.pack.outputs.nupkg || '' }}", + WorkflowJob.Env(testStep, WorkflowContract.ExactPackageVariable)); + Assert.Equal("${{ steps.pack.outputs.sha256 }}", WorkflowJob.Env(testStep, "PACKED_NUPKG_SHA256")); + string testRun = WorkflowFile.Scalar(testStep, "run") ?? ""; + Assert.Contains("$consumed -ne $env:PACKED_NUPKG_SHA256", testRun, StringComparison.Ordinal); + Assert.Contains("Out-File -FilePath $env:GITHUB_STEP_SUMMARY", testRun, StringComparison.Ordinal); + + Assert.Equal("artifacts/nuget/*.nupkg", WorkflowJob.With(job.Steps[upload], "path")); + + // The script asserts one package, its exact name when a version is required, the SBOM and the CI-built bridge. + string script = ReadRepositoryText("eng/ci/Test-SdkPackage.ps1"); + Assert.Contains("$sbomEntry = '_manifest/spdx_2.2/manifest.spdx.json'", script, StringComparison.Ordinal); + Assert.Contains("\"nupkg=$($package.FullName)\"", script, StringComparison.Ordinal); + Assert.Contains("\"sha256=$sha256\"", script, StringComparison.Ordinal); + Assert.Contains("$package.Name -cne \"$packageId.$PackageVersion.nupkg\"", script, StringComparison.Ordinal); + } + + [Fact] + public void Debug_leg_excludes_packaging_tests_by_trait_never_by_skip() + { + string run = WorkflowFile.Scalar(Pipeline().Job(BuildTestJob).Step("Test"), "run") ?? ""; + + // A skipped test fails the run in both legs; packaging is excluded by an xUnit v3 trait filter instead. + int options = run.IndexOf("$options = @(", StringComparison.Ordinal); + int debug = run.IndexOf("if ($env:CONFIGURATION -eq 'Debug') {", StringComparison.Ordinal); + int release = run.IndexOf("else {", debug + 1, StringComparison.Ordinal); + int failSkips = run.IndexOf("'--fail-skips', 'on'", StringComparison.Ordinal); + int filter = run.IndexOf($"'--filter-not-trait', '{WorkflowContract.PackagingTrait}'", StringComparison.Ordinal); + Assert.True(options >= 0 && options < failSkips && failSkips < debug, + "--fail-skips on must be a common option of both legs."); + Assert.True(debug < filter && filter < release, "The packaging trait filter belongs to the Debug leg only."); + + // No other way to hide a test: no second filter, no ignored exit code, no retries in the required run. + Assert.Equal(1, Occurrences(run, "--filter")); + foreach (string forbidden in new[] { "--ignore-exit-code", "--retry-failed-tests", "TESTINGPLATFORM_EXITCODE_IGNORE" }) + { + Assert.DoesNotContain(forbidden, run, StringComparison.Ordinal); + } + + // The Debug leg keeps the managed ABI comparison against the native fixture mandatory (audit A04-04). + Assert.Contains("$env:CE77_NATIVE_ABI_REQUIRED = 'true'", run, StringComparison.Ordinal); + } + + [Fact] + public void Test_step_runs_every_module_once_with_the_contract_options() + { + string run = WorkflowFile.Scalar(Pipeline().Job(BuildTestJob).Step("Test"), "run") ?? ""; + + Assert.Equal(1, Occurrences(run, "dotnet test")); + Assert.Contains("dotnet test @options", run, StringComparison.Ordinal); + foreach (string option in new[] + { + "'--solution', 'CheatEngine.SDK.slnx'", "'--no-build'", "'--results-directory', $env:RESULTS", "'--report-trx'", + "'--report-gh', '--report-gh-groups', 'off'", "'--hangdump', '--hangdump-timeout'", "'--crashdump'", + "'--coverage', '--coverage-output-format', 'xml'" + }) + { + Assert.Contains(option, run, StringComparison.Ordinal); + } + + // SDK 10 passes MTP options directly: a '--' separator would hand them to the wrong parser. + Assert.DoesNotContain("'--',", run, StringComparison.Ordinal); + } + + [Fact] + public void Every_test_module_references_the_extensions_the_test_step_uses() + { + // dotnet test passes every option to every module; a module without the extension fails with exit code 5. + string run = WorkflowFile.Scalar(Pipeline().Job(BuildTestJob).Step("Test"), "run") ?? ""; + XDocument props = XDocument.Parse(ReadRepositoryText("eng/Tests.props")); + HashSet referenced = new(StringComparer.Ordinal); + foreach (XElement group in props.Descendants("ItemGroup")) + { + if (!((string?) group.Attribute("Condition") ?? "").Contains("$(MSBuildProjectName.EndsWith('.Tests'))", StringComparison.Ordinal)) + { + continue; + } + + foreach (XElement reference in group.Elements("PackageReference")) + { + referenced.Add((string?) reference.Attribute("Include") ?? ""); + } + } + + foreach ((string option, string package) in s_testOptionExtensions) + { + Assert.Contains($"'{option}'", run, StringComparison.Ordinal); + Assert.True(referenced.Contains(package), + $"The Test step passes {option}; every *.Tests project needs {package} through eng/Tests.props."); + } + } + + [Fact] + public void Hang_dump_timeout_is_well_below_the_build_test_job_timeout() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + string run = WorkflowFile.Scalar(job.Step("Test"), "run") ?? ""; + Match hang = HangDumpTimeout().Match(run); + Assert.True(hang.Success, "The Test step must pass '--hangdump-timeout', 'm'."); + int hangMinutes = int.Parse(hang.Groups["minutes"].Value, NumberStyles.None, CultureInfo.InvariantCulture); + int jobMinutes = int.Parse(WorkflowFile.Scalar(job.Node, "timeout-minutes") ?? "0", NumberStyles.None, CultureInfo.InvariantCulture); + + // The dump must be written, uploaded and the job reported well before the runner kills it. + Assert.True(hangMinutes > 0 && hangMinutes * 2 <= jobMinutes, + $"--hangdump-timeout {hangMinutes}m must be at most half of build-test's timeout-minutes ({jobMinutes})."); + } + + [Fact] + public void Test_module_inventory_runs_in_both_legs() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + YamlMappingNode inventory = job.Step("Check test module inventory"); + Assert.True(job.StepIndex("Test") < job.StepIndex("Check test module inventory")); + Assert.Equal("${{ !cancelled() && steps.test.outcome != 'skipped' }}", WorkflowFile.Scalar(inventory, "if")); + string run = WorkflowFile.Scalar(inventory, "run") ?? ""; + Assert.Contains("./eng/ci/Test-TestModuleInventory.ps1 @options", run, StringComparison.Ordinal); + Assert.Contains("$options.RequireCoverage = $true", run, StringComparison.Ordinal); + + // The expected set is every tests/**/*.Tests.csproj git knows, never a hard-coded list. + string script = ReadRepositoryText("eng/ci/Test-TestModuleInventory.ps1"); + Assert.Contains("'tests/*.Tests.csproj'", script, StringComparison.Ordinal); + Assert.Contains("'^(?.+)_(?net\\d+\\.\\d+)_(?x64|x86|arm64)\\.trx$'", script, StringComparison.Ordinal); + Assert.Contains("executed no test", script, StringComparison.Ordinal); + } + + [Fact] + public void Every_uploaded_artifact_name_is_reserved() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + HashSet names = new(StringComparer.Ordinal); + foreach (WorkflowJob job in workflow.Jobs()) + { + foreach (YamlMappingNode step in job.StepsUsing("actions/upload-artifact@")) + { + string template = WorkflowJob.With(step, "name") ?? ""; + string retention = WorkflowJob.With(step, "retention-days") ?? ""; + foreach (string name in ExpandConfiguration(template)) + { + Assert.True(names.Add(name), $"{workflow.FileName} uploads '{name}' twice; artifact names are unique per run."); + } + + string? expectedRetention = ExpectedRetention(template); + Assert.True(expectedRetention is not null || IsReservedWithoutRetention(template), + $"{job.Location} uploads '{template}', which is not a reserved artifact name (shared contract 1.9)."); + if (expectedRetention is not null) + { + Assert.True(string.Equals(expectedRetention, retention, StringComparison.Ordinal), + $"{job.Location} keeps '{template}' {retention} days; the contract says {expectedRetention}."); + } + } + } + } + } + + [Fact] + public void Binlogs_are_uploaded_only_on_failure_and_never_from_sonar_or_release() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + bool mayLogBuilds = workflow.FileName is not (WorkflowContract.Sonar or "release.yml"); + foreach (WorkflowJob job in workflow.Jobs()) + { + // A binary log records the environment the build saw: never where a token or a signing step lives. + Assert.True(mayLogBuilds || !BinaryLogSwitch().IsMatch(job.RunText()), + $"{job.Location} writes a binary log; sonar.yml and release.yml never do."); + foreach (YamlMappingNode step in job.StepsUsing("actions/upload-artifact@")) + { + string name = WorkflowJob.With(step, "name") ?? ""; + if (name.StartsWith("binlogs-", StringComparison.Ordinal) || name.StartsWith("test-dumps-", StringComparison.Ordinal)) + { + Assert.True(mayLogBuilds, $"{job.Location} uploads '{name}'; sonar.yml and release.yml never do."); + Assert.True(string.Equals(WorkflowFile.Scalar(step, "if"), "failure()", StringComparison.Ordinal), + $"{job.Location} uploads '{name}' outside 'if: failure()'."); + } + } + } + } + + // The jobs that build, pack or publish keep their logs for a failed run. + WorkflowFile pipeline = Pipeline(); + Assert.Single(pipeline.Job(BuildTestJob).StepsUsing("actions/upload-artifact@"), + static step => string.Equals(WorkflowJob.With(step, "name"), "binlogs-build-test-${{ matrix.configuration }}", StringComparison.Ordinal)); + Assert.Single(pipeline.Job("aot").StepsUsing("actions/upload-artifact@"), + static step => string.Equals(WorkflowJob.With(step, "name"), "binlogs-aot", StringComparison.Ordinal)); + Assert.Equal(3, BinaryLogSwitch().Count(pipeline.Job("aot").RunText())); + } + + [Fact] + public void Jobs_that_pack_or_test_fetch_full_history() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + foreach (WorkflowJob job in workflow.Jobs()) + { + string run = job.RunText(); + bool packs = VersionedPack().IsMatch(run) || run.Contains("dotnet-sonarscanner", StringComparison.Ordinal); + bool builds = VersionedBuild().IsMatch(run); + if (!packs && !builds) + { + continue; + } + + // MinVer computes the version from tags and history; a shallow clone packs 0.0.0-alpha.0. + YamlMappingNode checkout = Assert.Single(job.StepsUsing("actions/checkout@")); + bool fullHistory = string.Equals(WorkflowJob.With(checkout, "fetch-depth"), "0", StringComparison.Ordinal); + bool skipsVersioning = !packs && run.Contains("MinVerSkip=true", StringComparison.Ordinal); + Assert.True(fullHistory || skipsVersioning, + $"{job.Location} builds, packs, tests or analyses: check out with fetch-depth: 0 (MinVer)."); + } + } + } + + [Fact] + public void Aot_job_publishes_the_native_aot_probes() + { + WorkflowJob aot = Pipeline().Job("aot"); + Assert.Equal(["native"], aot.Needs()); + + // The three probes restore locked through the composite action; their locks carry the win-x64 ILCompiler. + string[] probes = + [ + "tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj", + "tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj", + "tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj" + ]; + YamlMappingNode setup = Assert.Single(aot.StepsUsing(WorkflowContract.SetupAction)); + Assert.Equal(probes, RestoreTargets(setup), StringComparer.Ordinal); + + // Publication and inspection only: a NativeAOT publish is never presented as a Cheat Engine load (audit A20-07). + string run = aot.RunText(); + Assert.Contains("dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore", run, + StringComparison.Ordinal); + Assert.Contains("$libraryProject = 'tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj'", + run, StringComparison.Ordinal); + Assert.Contains("dotnet publish $libraryProject -c Release --no-restore", run, StringComparison.Ordinal); + Assert.Contains("& $harness --analyze $library", run, StringComparison.Ordinal); + Assert.Contains("lua-protection-bridge", WorkflowJob.With(Assert.Single(aot.StepsUsing("actions/download-artifact@")), "name"), + StringComparison.Ordinal); + } + + [Fact] + public void Live_probe_is_compiled_by_the_ci_solution_build() + { + WorkflowJob job = Pipeline().Job(BuildTestJob); + bool inSolution = SolutionProjects().Contains(WorkflowContract.LiveProbeProject); + int explicitStep = job.StepIndex("Compile live probe"); + bool restoredExplicitly = RestoreTargets(Assert.Single(job.StepsUsing(WorkflowContract.SetupAction))) + .Contains(WorkflowContract.LiveProbeProject); + + // C0 only (PR-CQ-60): compiled once per run, by the solution build as soon as the probe is in the solution. + if (inSolution) + { + Assert.True(explicitStep < 0 && !restoredExplicitly, + $"{WorkflowContract.LiveProbeProject} is in CheatEngine.SDK.slnx, so the solution build compiles it: delete the " + + "'Compile live probe' step of build-test and its line in the step's composite restore list."); + } + else + { + Assert.True(explicitStep > job.StepIndex("Build") && restoredExplicitly, + "Until the live probe joins CheatEngine.SDK.slnx, build-test restores it and compiles it in a 'Compile live probe' step."); + YamlMappingNode step = job.Steps[explicitStep]; + Assert.Equal("matrix.configuration == 'Release'", WorkflowFile.Scalar(step, "if")); + Assert.Contains($"dotnet build {WorkflowContract.LiveProbeProject} -c Release --no-restore", + WorkflowFile.Scalar(step, "run"), StringComparison.Ordinal); + } + + // Never loaded, run or shipped by CI: nothing uploads its output. + foreach (WorkflowJob pipelineJob in Pipeline().Jobs()) + { + foreach (YamlMappingNode upload in pipelineJob.StepsUsing("actions/upload-artifact@")) + { + Assert.DoesNotContain("LiveProbe", WorkflowJob.With(upload, "path") ?? "", StringComparison.OrdinalIgnoreCase); + } + } + } + + /// The newline-separated restore input of a composite setup step. + private static List RestoreTargets(YamlMappingNode setupStep) + { + List targets = []; + foreach (string line in (WorkflowJob.With(setupStep, "restore") ?? "").Split('\n')) + { + if (line.Trim().Length > 0) + { + targets.Add(line.Trim()); + } + } + + return targets; + } + + /// The project paths CheatEngine.SDK.slnx lists. + private static HashSet SolutionProjects() + { + HashSet projects = new(StringComparer.Ordinal); + foreach (XElement project in XDocument.Load(RepositoryRoot.SolutionPath).Descendants("Project")) + { + projects.Add(((string?) project.Attribute("Path") ?? "").Replace('\\', '/')); + } + + return projects; + } + + private static int Occurrences(string text, string value) + { + int count = 0; + for (int index = text.IndexOf(value, StringComparison.Ordinal); + index >= 0; + index = text.IndexOf(value, index + value.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } + + private static IEnumerable ExpandConfiguration(string template) + { + const string placeholder = "${{ matrix.configuration }}"; + if (!template.Contains(placeholder, StringComparison.Ordinal)) + { + return [template]; + } + + return + [ + template.Replace(placeholder, "Debug", StringComparison.Ordinal), + template.Replace(placeholder, "Release", StringComparison.Ordinal) + ]; + } + + /// The contract retention of a reserved name, or null when the name is not reserved with one. + private static string? ExpectedRetention(string template) + { + string key = template.Replace("${{ matrix.configuration }}", "{configuration}", StringComparison.Ordinal); + if (WorkflowContract.ReservedArtifacts.TryGetValue(key, out string? retention)) + { + return retention; + } + + return BinlogName().IsMatch(template) ? WorkflowContract.BinlogRetention : null; + } + + private static bool IsReservedWithoutRetention(string template) + { + return WorkflowContract.ReservedArtifacts.TryGetValue(template, out string? retention) && retention is null; + } + + [GeneratedRegex(@"'--hangdump-timeout',\s*'(?\d+)m'", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex HangDumpTimeout(); + + [GeneratedRegex(@"\bdotnet\s+pack\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex VersionedPack(); + + [GeneratedRegex(@"\bdotnet\s+(?:build|test|publish)\b", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex VersionedBuild(); + + [GeneratedRegex(@"(?:^|\s|"")[-/]bl(?::|\s|$)", RegexOptions.Multiline | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex BinaryLogSwitch(); + + [GeneratedRegex(@"^binlogs-[a-z0-9-]+?(?:-\$\{\{ matrix\.configuration \}\})?$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex BinlogName(); +} From 36e9b74219d67dc0d99c1806df0cacc7e529fd4a Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:51:00 +0200 Subject: [PATCH 052/199] Merge coverage and enforce a per-assembly ratchet Each test module wrote its own coverage report and only Sonar read them: coverage could fall without any check failing (audit register PR-CQ-26). The Debug leg now runs eng/ci/Test-CoverageBaseline.ps1 after the module inventory. It merges the per-module reports with dotnet-coverage 18.11.2, pinned in .config/dotnet-tools.json on the same version as the Microsoft.Testing.Extensions.CodeCoverage collector (roll-forward allowed: the tool targets .NET 8 and the runner may only expose the pinned .NET 10 runtime), keeps the eleven shipping assemblies (the ProjectReference items of src/CheatEngine.SDK, generated sources excluded), and compares each line coverage with its floor in eng/coverage-baseline.json minus an explicit 0.5-point tolerance. A drop fails with the assembly, floor and value; the step summary and the coverage-report artifact carry the merged Cobertura report and a suggested baseline that CI never writes. Line coverage is the only metric: the merge keeps no branch data (every merged branch-rate reads 1), so a branch floor would prove nothing. The floors are the suggestion of a local run of the CI Debug options: dotnet test --solution CheatEngine.SDK.slnx -c Debug --no-build --fail-skips on --report-trx --hangdump --hangdump-timeout 15m --crashdump --coverage --coverage-output-format xml --filter-not-trait "Category=Packaging" (13 modules, 2439 tests, all shipping assemblies 86.78 % of lines). dotnet-coverage merge: https://learn.microsoft.com/dotnet/core/additional-tools/dotnet-coverage#dotnet-coverage-merge Tool roll-forward: https://learn.microsoft.com/dotnet/core/tools/dotnet-tool-run --- .config/dotnet-tools.json | 13 ++ .github/workflows/ci.yml | 20 ++ CheatEngine.SDK.slnx | 3 + eng/ci/Test-CoverageBaseline.ps1 | 210 ++++++++++++++++++ eng/coverage-baseline.json | 39 ++++ .../Workflows/CoverageBaselineTests.cs | 137 ++++++++++++ 6 files changed, 422 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 eng/ci/Test-CoverageBaseline.ps1 create mode 100644 eng/coverage-baseline.json create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/CoverageBaselineTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..330fcffd --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-coverage": { + "version": "18.11.2", + "commands": [ + "dotnet-coverage" + ], + "rollForward": true + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 689a38f0..f6fc5c2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,6 +253,17 @@ jobs: throw "The test module inventory failed with exit code $LASTEXITCODE." } + # Line union of every module, held to the per-assembly floors of eng/coverage-baseline.json. CI only suggests a + # new baseline (step summary and coverage-report artifact); raising a floor is a reviewed commit. + - name: Check coverage floors + id: coverage + if: matrix.configuration == 'Debug' + run: | + ./eng/ci/Test-CoverageBaseline.ps1 -ResultsDirectory $env:RESULTS -Baseline eng/coverage-baseline.json -OutputDirectory artifacts/coverage-report + if ($LASTEXITCODE -ne 0) { + throw "The coverage check failed with exit code $LASTEXITCODE." + } + # The uploaded file is the file the tests consumed. - name: Verify the tested package is unchanged if: matrix.configuration == 'Release' @@ -283,6 +294,15 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Upload coverage report + if: ${{ !cancelled() && matrix.configuration == 'Debug' && steps.coverage.outcome != 'skipped' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report + path: artifacts/coverage-report/ + if-no-files-found: warn + retention-days: 7 + - name: Upload test results if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 993a3848..4e97db1c 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -1,5 +1,6 @@ + @@ -32,6 +33,7 @@ + @@ -48,6 +50,7 @@ + diff --git a/eng/ci/Test-CoverageBaseline.ps1 b/eng/ci/Test-CoverageBaseline.ps1 new file mode 100644 index 00000000..87bd2178 --- /dev/null +++ b/eng/ci/Test-CoverageBaseline.ps1 @@ -0,0 +1,210 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Merges the per-module coverage reports and holds each shipping assembly to its line-coverage floor. + +.DESCRIPTION + The Debug leg writes one Microsoft Code Coverage XML report per test module (the format SonarQube Cloud reads). + This script: + 1. merges them with the pinned dotnet-coverage tool (.config/dotnet-tools.json) into a Cobertura report, the + line union of every module of the same build; + 2. keeps the shipping assemblies only, the ProjectReference items of src/CheatEngine.SDK/CheatEngine.SDK.csproj + (test assemblies are excluded by the collector), and drops generated sources (*.g.cs, anything under obj/), + which Sonar does not count either; + 3. compares each assembly's line coverage with its floor in eng/coverage-baseline.json, minus the file's + tolerance (percentage points), and fails naming the assembly, floor and value when one is below; + 4. writes a Markdown summary and suggested-coverage-baseline.json (every value floored to 0.1) to the output + directory and the step summary. CI never edits eng/coverage-baseline.json: raising a floor is a reviewed + commit that copies the suggestion. + + Line coverage is the metric because it is what survives the merge: the collector records blocks, not branch + conditions, and dotnet-coverage merges by line (the merged report has no block counts and marks no line as a + branch), so a branch floor would always read 100 %. + +.PARAMETER ResultsDirectory + The Debug --results-directory; its top-level *.xml files are the per-module reports. + +.PARAMETER Cobertura + An already merged Cobertura report, instead of -ResultsDirectory. + +.PARAMETER Baseline + The floors file. + +.PARAMETER OutputDirectory + Receives merged.cobertura.xml, summary.md and suggested-coverage-baseline.json. + +.EXAMPLE + ./eng/ci/Test-CoverageBaseline.ps1 -ResultsDirectory artifacts/test-results/Debug +#> +[CmdletBinding(DefaultParameterSetName = 'Results')] +param( + [Parameter(Mandatory, ParameterSetName = 'Results')] + [string] $ResultsDirectory, + + [Parameter(Mandatory, ParameterSetName = 'Merged')] + [string] $Cobertura, + + [string] $Baseline = 'eng/coverage-baseline.json', + + [string] $OutputDirectory = 'artifacts/coverage-report' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$InformationPreference = 'Continue' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$baselineSchema = 'cheatengine-coverage-baseline/v0' +$packageProject = 'src/CheatEngine.SDK/CheatEngine.SDK.csproj' +$env:DOTNET_COVERAGE_TELEMETRY_OPTOUT = '1' +$env:DOTNET_COVERAGE_NOLOGO = '1' + +function Get-FlooredPercent { + param([Parameter(Mandatory)] [double] $Value) + + return [Math]::Floor($Value * 10) / 10 +} + +# The shipping assemblies are exactly what the package embeds or packs as analyzers. +[xml] $project = [IO.File]::ReadAllText((Join-Path $repositoryRoot $packageProject)) +$shipping = [Collections.Generic.SortedSet[string]]::new([StringComparer]::Ordinal) +foreach ($reference in @($project.Project.ItemGroup | ForEach-Object { $_.ChildNodes } | Where-Object { $_.LocalName -eq 'ProjectReference' })) { + [void] $shipping.Add([IO.Path]::GetFileNameWithoutExtension($reference.Include.Replace('\', '/'))) +} +if ($shipping.Count -eq 0) { + throw "$packageProject lists no ProjectReference." +} + +$baselinePath = [IO.Path]::GetFullPath($Baseline, $PWD.Path) +$floors = Get-Content -LiteralPath $baselinePath -Raw | ConvertFrom-Json -AsHashtable +if ($floors['schema'] -cne $baselineSchema) { + throw "$Baseline must declare schema '$baselineSchema'." +} +$tolerance = [double] $floors['tolerance'] +if ($tolerance -lt 0 -or $tolerance -gt 5) { + throw "$Baseline tolerance must be between 0 and 5 percentage points, found $tolerance." +} +$listed = [Collections.Generic.SortedSet[string]]::new([string[]] @($floors['assemblies'].Keys), [StringComparer]::Ordinal) +if (-not $listed.SetEquals($shipping)) { + throw "$Baseline must list exactly the shipping assemblies of ${packageProject}: $($shipping -join ', ')." +} + +$output = [IO.Path]::GetFullPath($OutputDirectory, $PWD.Path) +New-Item -ItemType Directory -Path $output -Force | Out-Null +if ($PSCmdlet.ParameterSetName -eq 'Results') { + $reports = @(Get-ChildItem -LiteralPath ([IO.Path]::GetFullPath($ResultsDirectory, $PWD.Path)) -Filter '*.xml' -File | + ForEach-Object FullName) + if ($reports.Count -eq 0) { + throw "No coverage report (*.xml) in '$ResultsDirectory'." + } + $Cobertura = Join-Path $output 'merged.cobertura.xml' + Push-Location -LiteralPath $repositoryRoot + try { + dotnet tool restore | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "dotnet tool restore failed with exit code $LASTEXITCODE." + } + dotnet tool run dotnet-coverage merge --output $Cobertura --output-format cobertura @reports | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "dotnet-coverage merge failed with exit code $LASTEXITCODE." + } + } + finally { + Pop-Location + } +} + +[xml] $report = [IO.File]::ReadAllText([IO.Path]::GetFullPath($Cobertura, $PWD.Path)) +$measured = @{} +foreach ($package in @($report.coverage.packages.package)) { + if (-not $shipping.Contains($package.name)) { + continue + } + # A (file, line) pair can appear under several classes (partial and nested types); it is covered once any is hit. + $lines = @{} + foreach ($class in @($package.classes.class)) { + $file = ([string] $class.filename).Replace('\', '/') + if ($file.EndsWith('.g.cs', [StringComparison]::OrdinalIgnoreCase) -or $file -match '(^|/)obj/') { + continue + } + foreach ($line in @($class.lines.line)) { + $key = "$file|$($line.number)" + $lines[$key] = ($lines[$key] -eq $true) -or ([long] $line.hits -gt 0) + } + } + $covered = @($lines.Values | Where-Object { $_ }).Count + $measured[$package.name] = [pscustomobject] @{ + Covered = $covered + Valid = $lines.Count + Percent = if ($lines.Count -eq 0) { 0.0 } else { 100.0 * $covered / $lines.Count } + } +} + +$problems = [Collections.Generic.List[string]]::new() +$rows = [Collections.Generic.List[string]]::new() +$suggested = [ordered]@{} +$totalCovered = 0 +$totalValid = 0 +foreach ($assembly in $shipping) { + $floor = [double] $floors['assemblies'][$assembly]['line'] + if (-not $measured.ContainsKey($assembly)) { + $problems.Add("$assembly was not measured: no test module loaded it with coverage.") + $rows.Add("| $assembly | not measured | $floor | failed |") + $suggested[$assembly] = [ordered]@{ line = $floor } + continue + } + $actual = $measured[$assembly] + $totalCovered += $actual.Covered + $totalValid += $actual.Valid + $percent = [Math]::Round($actual.Percent, 2) + $status = 'ok' + if ($actual.Percent -lt $floor - $tolerance) { + $status = 'below floor' + $problems.Add("$assembly line coverage is $percent %, below its floor of $floor % (tolerance $tolerance points).") + } + elseif ((Get-FlooredPercent -Value $actual.Percent) -gt $floor) { + $status = 'above floor: raise it' + } + $rows.Add("| $assembly | $percent ($($actual.Covered)/$($actual.Valid)) | $floor | $status |") + $suggested[$assembly] = [ordered]@{ line = [Math]::Max($floor, (Get-FlooredPercent -Value $actual.Percent)) } +} + +$suggestion = [ordered]@{ + schema = $baselineSchema + tolerance = $tolerance + assemblies = $suggested +} +$suggestionJson = ConvertTo-Json -InputObject $suggestion -Depth 4 +[IO.File]::WriteAllText((Join-Path $output 'suggested-coverage-baseline.json'), $suggestionJson + "`n", [Text.UTF8Encoding]::new($false)) + +$overall = if ($totalValid -eq 0) { 0 } else { [Math]::Round(100.0 * $totalCovered / $totalValid, 2) } +$summary = @( + '### Coverage (shipping assemblies, line union of every test module)' + '' + '| Assembly | Line % (covered/valid) | Floor % | Status |' + '| --- | ---: | ---: | --- |' +) + $rows + @( + "| **All shipping assemblies** | **$overall ($totalCovered/$totalValid)** | | |" + '' + "Tolerance: $tolerance percentage points. Generated sources (*.g.cs, obj/) are excluded." + '' + '
Suggested eng/coverage-baseline.json (CI never writes it)' + '' + '```json' + $suggestionJson + '```' + '' + '
' +) +[IO.File]::WriteAllLines((Join-Path $output 'summary.md'), [string[]] $summary, [Text.UTF8Encoding]::new($false)) +if ($env:GITHUB_STEP_SUMMARY) { + $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +} +$summary | ForEach-Object { Write-Information $_ } + +if ($problems.Count -gt 0) { + foreach ($problem in $problems) { + Write-Host "::error title=Coverage floor::$problem" + } + throw "Coverage fell below the floors of ${Baseline}: $($problems -join ' ')" +} diff --git a/eng/coverage-baseline.json b/eng/coverage-baseline.json new file mode 100644 index 00000000..10a19520 --- /dev/null +++ b/eng/coverage-baseline.json @@ -0,0 +1,39 @@ +{ + "schema": "cheatengine-coverage-baseline/v0", + "tolerance": 0.5, + "assemblies": { + "CheatEngine.SDK.Abi": { + "line": 83.1 + }, + "CheatEngine.SDK.Analyzers": { + "line": 88.9 + }, + "CheatEngine.SDK.Analyzers.CodeFixes": { + "line": 88.0 + }, + "CheatEngine.SDK.Annotations": { + "line": 0.0 + }, + "CheatEngine.SDK.Engine": { + "line": 79.7 + }, + "CheatEngine.SDK.Hosting": { + "line": 90.7 + }, + "CheatEngine.SDK.Lua": { + "line": 86.4 + }, + "CheatEngine.SDK.Lua.Interop": { + "line": 93.9 + }, + "CheatEngine.SDK.SourceGenerators.EntryPoint": { + "line": 98.6 + }, + "CheatEngine.SDK.SourceGenerators.LuaBindings": { + "line": 95.2 + }, + "CheatEngine.SDK.SourceGenerators.Shared": { + "line": 94.6 + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/CoverageBaselineTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/CoverageBaselineTests.cs new file mode 100644 index 00000000..4f54778c --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/CoverageBaselineTests.cs @@ -0,0 +1,137 @@ +using System.Text.Json; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// The coverage ratchet of the Debug leg: eng/coverage-baseline.json holds a line-coverage floor for exactly the +/// assemblies the CheatEngine.SDK package ships, eng/ci/Test-CoverageBaseline.ps1 enforces it on the merged report of +/// every test module, and the merge tool is pinned in the local tool manifest. +/// +public sealed class CoverageBaselineTests +{ + private const string BaselinePath = "eng/coverage-baseline.json"; + private const string ScriptPath = "eng/ci/Test-CoverageBaseline.ps1"; + private const string ToolManifestPath = ".config/dotnet-tools.json"; + private const string PackageProjectPath = "src/CheatEngine.SDK/CheatEngine.SDK.csproj"; + private const string BaselineSchema = "cheatengine-coverage-baseline/v0"; + + [Fact] + public void Coverage_baseline_lists_exactly_the_shipping_assemblies() + { + // Shipping = every project the package embeds under lib/ or packs under analyzers/, read from the package project. + SortedSet shipping = new(StringComparer.Ordinal); + foreach (XElement reference in XDocument.Load(RepositoryFile(PackageProjectPath)).Descendants("ProjectReference")) + { + string include = ((string?) reference.Attribute("Include") ?? "").Replace('\\', '/'); + shipping.Add(Path.GetFileNameWithoutExtension(include)); + } + + Assert.NotEmpty(shipping); + using JsonDocument baseline = ReadBaseline(); + SortedSet listed = new(StringComparer.Ordinal); + foreach (JsonProperty assembly in baseline.RootElement.GetProperty("assemblies").EnumerateObject()) + { + listed.Add(assembly.Name); + } + + Assert.Equal(shipping, listed); + } + + [Fact] + public void Coverage_floors_are_percentages_and_the_tolerance_is_explicit() + { + using JsonDocument baseline = ReadBaseline(); + JsonElement root = baseline.RootElement; + + List keys = []; + foreach (JsonProperty property in root.EnumerateObject()) + { + keys.Add(property.Name); + } + + Assert.Equal(["schema", "tolerance", "assemblies"], keys); + Assert.Equal(BaselineSchema, root.GetProperty("schema").GetString()); + double tolerance = root.GetProperty("tolerance").GetDouble(); + Assert.True(tolerance is > 0 and <= 5, $"The tolerance must be a small positive number of percentage points, found {tolerance}."); + + foreach (JsonProperty assembly in root.GetProperty("assemblies").EnumerateObject()) + { + // Line coverage only: the merged report keeps no block or branch data (see Test-CoverageBaseline.ps1). + List metrics = []; + foreach (JsonProperty metric in assembly.Value.EnumerateObject()) + { + metrics.Add(metric.Name); + } + + Assert.Equal(["line"], metrics); + double floor = assembly.Value.GetProperty("line").GetDouble(); + Assert.True(floor is >= 0 and <= 100, $"{assembly.Name} has the floor {floor}, which is not a percentage."); + // Floors are floored to 0.1 when copied from the CI suggestion, so they never claim more than was measured. + Assert.Equal(Math.Round(floor, 1), floor); + } + } + + [Fact] + public void Coverage_tool_is_pinned_in_the_local_tool_manifest() + { + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(RepositoryFile(ToolManifestPath))); + JsonElement root = manifest.RootElement; + Assert.True(root.GetProperty("isRoot").GetBoolean(), $"{ToolManifestPath} must be a root manifest."); + + JsonProperty tool = Assert.Single(root.GetProperty("tools").EnumerateObject()); + Assert.Equal("dotnet-coverage", tool.Name); + // dotnet-coverage targets .NET 8; runners that expose only the pinned .NET 10 runtime need the Major roll-forward. + // https://learn.microsoft.com/dotnet/core/tools/dotnet-tool-run + Assert.True(tool.Value.GetProperty("rollForward").GetBoolean()); + string version = tool.Value.GetProperty("version").GetString() ?? ""; + Assert.Matches(@"^\d+\.\d+\.\d+$", version); + + // The merge tool and the collector the tests use ship together: keep them on the same version. + XElement collector = Assert.Single(XDocument.Load(RepositoryFile("Directory.Packages.props")).Descendants("PackageVersion"), + static element => string.Equals((string?) element.Attribute("Include"), "Microsoft.Testing.Extensions.CodeCoverage", + StringComparison.Ordinal)); + Assert.Equal((string?) collector.Attribute("Version"), version); + } + + [Fact] + public void Debug_leg_checks_the_coverage_floors_and_never_writes_the_baseline() + { + WorkflowJob job = WorkflowFile.LoadWorkflow(WorkflowContract.Pipeline).Job("build-test"); + YamlMappingNode check = job.Step("Check coverage floors"); + Assert.Equal("matrix.configuration == 'Debug'", WorkflowFile.Scalar(check, "if")); + Assert.Contains($"./{ScriptPath} -ResultsDirectory $env:RESULTS -Baseline {BaselinePath}", + WorkflowFile.Scalar(check, "run"), StringComparison.Ordinal); + Assert.True(job.StepIndex("Check test module inventory") < job.StepIndex("Check coverage floors")); + YamlMappingNode upload = Assert.Single(job.StepsUsing("actions/upload-artifact@"), + static step => string.Equals(WorkflowJob.With(step, "name"), "coverage-report", StringComparison.Ordinal)); + Assert.Equal("artifacts/coverage-report/", WorkflowJob.With(upload, "path")); + + // The script merges with the pinned tool and only suggests a new baseline; raising a floor is a reviewed commit. + string script = File.ReadAllText(RepositoryFile(ScriptPath)); + Assert.Contains("dotnet tool run dotnet-coverage merge", script, StringComparison.Ordinal); + Assert.Contains("suggested-coverage-baseline.json", script, StringComparison.Ordinal); + foreach (string line in script.Split('\n')) + { + bool writes = line.Contains("Set-Content", StringComparison.Ordinal) || line.Contains("WriteAll", StringComparison.Ordinal) || + line.Contains("Out-File", StringComparison.Ordinal); + Assert.False(writes && line.Contains("$baselinePath", StringComparison.Ordinal), + $"{ScriptPath} must never write the baseline: '{line.Trim()}'."); + } + } + + private static JsonDocument ReadBaseline() + { + return JsonDocument.Parse(File.ReadAllText(RepositoryFile(BaselinePath))); + } + + private static string RepositoryFile(string relativePath) + { + string path = Path.Combine(RepositoryRoot.Path, relativePath); + Assert.True(File.Exists(path), $"{relativePath} is missing."); + return path; + } +} From 9d07aab833ab3307ba23fae5bea236f60f2fb277 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:51:26 +0200 Subject: [PATCH 053/199] Publish build info from the Release leg Nothing recorded which source, run, .NET SDK, runner image and native toolchain produced a CI package, so a package could not be traced back to the conditions that built it (audit ch.21 exit criteria, A21-08, ADR-12). After Pack, the Release leg runs eng/ci/New-BuildInfo.ps1 and uploads build-info.json (artifact build-info, the package retention). The document, cheatengine-build-info/v0 of shared contract 1.10, records the repository, commit, tree hash, ref, event, run, pull request head, the .NET SDK (which must equal global.json), the SHA-256 of global.json, the runner label and image, the bridge toolchain and hashes from the native job's outputs, and the package id, version, file, SHA-256, SHA-512 and SBOM entry. The writer refuses a missing or malformed value, a checkout HEAD other than GITHUB_SHA and a packed bridge other than the CI-built one, and writes UTF-8 without BOM, lowercase hashes and no local path. eng/ci/build-info.v0.schema.json (draft 2020-12) declares exactly those fields with additionalProperties: false at every level. The tests compare its required lists with the contract table, check that the writer emits every field and that the workflow passes exactly the variables the writer reads, each naming a declared native output. Checked locally on a packed CheatEngine.SDK 2.0.0-alpha.0.62 with a simulated GitHub environment: the document matches every required list, pattern and enum of the schema. --- .github/workflows/ci.yml | 31 ++- CheatEngine.SDK.slnx | 2 + eng/ci/New-BuildInfo.ps1 | 231 +++++++++++++++++ eng/ci/build-info.v0.schema.json | 240 ++++++++++++++++++ .../Workflows/BuildInfoSchemaTests.cs | 217 ++++++++++++++++ 5 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 eng/ci/New-BuildInfo.ps1 create mode 100644 eng/ci/build-info.v0.schema.json create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/BuildInfoSchemaTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6fc5c2d..6a498d0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ on: type: string default: '' package-retention-days: - description: Days to keep the nuget-package artifact. + description: Days to keep the nuget-package and build-info artifacts. type: number default: 7 secrets: @@ -206,6 +206,26 @@ jobs: throw "The packed SDK failed its checks with exit code $LASTEXITCODE." } + - name: Write build info + if: matrix.configuration == 'Release' + env: + PACKAGE: ${{ steps.pack.outputs.nupkg }} + BUILD_INFO_RUNNER_LABEL: windows-2025 # equal to this job's runs-on + BUILD_INFO_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + BUILD_INFO_PULL_REQUEST_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BUILD_INFO_XMAKE_VERSION: ${{ needs.native.outputs.xmake-version }} + BUILD_INFO_MSVC_TOOLSET: ${{ needs.native.outputs.msvc-toolset }} + BUILD_INFO_MSVC_VERSION: ${{ needs.native.outputs.msvc-version }} + BUILD_INFO_WINDOWS_SDK_VERSION: ${{ needs.native.outputs.windows-sdk-version }} + BUILD_INFO_BRIDGE_SHA256: ${{ needs.native.outputs.bridge-sha256 }} + BUILD_INFO_BRIDGE_FINGERPRINT: ${{ needs.native.outputs.bridge-fingerprint }} + BUILD_INFO_CHECKED_IN_BRIDGE_SHA256: ${{ needs.native.outputs.checked-in-bridge-sha256 }} + run: | + ./eng/ci/New-BuildInfo.ps1 -PackagePath $env:PACKAGE -OutputPath artifacts/build-info/build-info.json + if ($LASTEXITCODE -ne 0) { + throw "Writing build-info.json failed with exit code $LASTEXITCODE." + } + # One run over every tests/**/*.Tests module of the solution; MTP options go straight to dotnet test (SDK 10). # A skip fails the run (--fail-skips on): NativeLua tests skip when the bundled Lua DLL does not bind, and a green # check must not hide that. Hang and crash dumps need the extensions every module gets from eng/Tests.props. @@ -285,6 +305,15 @@ jobs: if-no-files-found: error retention-days: ${{ inputs.package-retention-days }} + - name: Upload build info + if: matrix.configuration == 'Release' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: build-info + path: artifacts/build-info/build-info.json + if-no-files-found: error + retention-days: ${{ inputs.package-retention-days }} + - name: Upload coverage if: matrix.configuration == 'Debug' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 4e97db1c..630c35ad 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -49,7 +49,9 @@
+ + diff --git a/eng/ci/New-BuildInfo.ps1 b/eng/ci/New-BuildInfo.ps1 new file mode 100644 index 00000000..f9f6337f --- /dev/null +++ b/eng/ci/New-BuildInfo.ps1 @@ -0,0 +1,231 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Writes build-info.json (cheatengine-build-info/v0) for the package the Release leg packed. + +.DESCRIPTION + build-info.json records where a CheatEngine.SDK package came from: repository, commit and tree, the workflow run, + the pull request head, the exact .NET SDK and global.json, the runner image, the native bridge toolchain and + hashes (from the native job's outputs), and the package identity with its SHA-256, SHA-512 and SBOM entry. It is + the precursor of the release tuple manifest (shared contract section 1.10) and is validated against + eng/ci/build-info.v0.schema.json by the repository tests. + + Every value comes from the environment GitHub Actions provides, or from the named environment variables below, + which ci.yml sets from job outputs; the script refuses to write a document with a missing or malformed value. + BUILD_INFO_RUNNER_LABEL the runs-on label of the job (a literal in ci.yml) + BUILD_INFO_PULL_REQUEST_NUMBER github.event.pull_request.number (empty outside pull requests) + BUILD_INFO_PULL_REQUEST_HEAD_SHA github.event.pull_request.head.sha (empty outside pull requests) + BUILD_INFO_XMAKE_VERSION needs.native.outputs.xmake-version + BUILD_INFO_MSVC_TOOLSET needs.native.outputs.msvc-toolset + BUILD_INFO_MSVC_VERSION needs.native.outputs.msvc-version + BUILD_INFO_WINDOWS_SDK_VERSION needs.native.outputs.windows-sdk-version + BUILD_INFO_BRIDGE_SHA256 needs.native.outputs.bridge-sha256 + BUILD_INFO_BRIDGE_FINGERPRINT needs.native.outputs.bridge-fingerprint + BUILD_INFO_CHECKED_IN_BRIDGE_SHA256 needs.native.outputs.checked-in-bridge-sha256 + + Encoding (shared contract section 2.0): UTF-8 without BOM, lowercase hexadecimal hashes, standard base64 SHA-512, + ISO 8601 UTC times, repository-relative paths only. globalJsonSha256 hashes global.json with CRLF normalized to LF, + that is the committed blob, so the value does not depend on the checkout's line endings. + +.PARAMETER PackagePath + The nupkg the Release leg packed (Test-SdkPackage.ps1 output `nupkg`). + +.PARAMETER OutputPath + Where to write the document. + +.EXAMPLE + ./eng/ci/New-BuildInfo.ps1 -PackagePath artifacts/nuget/CheatEngine.SDK.2.0.0-alpha.0.1.nupkg +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PackagePath, + + [string] $OutputPath = 'artifacts/build-info/build-info.json' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$InformationPreference = 'Continue' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$schemaId = 'cheatengine-build-info/v0' +$sbomEntry = '_manifest/spdx_2.2/manifest.spdx.json' +$bridgeEntry = 'build/native/cheatengine-sdk-lua-bridge.dll' +$sha256Pattern = '^[0-9a-f]{64}$' +$objectIdPattern = '^[0-9a-f]{40}$' + +function Get-RequiredValue { + param( + [Parameter(Mandatory)] [string] $Name, + [string] $Pattern = '.' + ) + + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Environment variable $Name is empty; build-info.json cannot be written without it." + } + $value = $value.Trim() + if ($value -cnotmatch $Pattern) { + throw "Environment variable $Name has the malformed value '$value' (expected $Pattern)." + } + return $value +} + +function Invoke-Git { + param([Parameter(Mandatory)] [string[]] $Arguments) + + $output = & git -C $repositoryRoot @Arguments + if ($LASTEXITCODE -ne 0) { + throw "git $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } + return "$output".Trim() +} + +$package = Get-Item -LiteralPath ([IO.Path]::GetFullPath($PackagePath, $PWD.Path)) +$packageBytes = [IO.File]::ReadAllBytes($package.FullName) + +# Package identity from the nuspec, and the bridge the package actually carries. +$archive = [IO.Compression.ZipArchive]::new([IO.MemoryStream]::new($packageBytes), [IO.Compression.ZipArchiveMode]::Read) +try { + $nuspecEntry = @($archive.Entries | Where-Object { $_.FullName -notmatch '/' -and $_.Name -like '*.nuspec' }) + if ($nuspecEntry.Count -ne 1) { + throw "$($package.Name) must hold exactly one nuspec at its root." + } + $reader = [IO.StreamReader]::new($nuspecEntry[0].Open()) + try { + [xml] $nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + if ($null -eq $archive.GetEntry($sbomEntry)) { + throw "$($package.Name) does not embed $sbomEntry." + } + $bridge = $archive.GetEntry($bridgeEntry) + if ($null -eq $bridge) { + throw "$($package.Name) does not carry $bridgeEntry." + } + $bridgeStream = $bridge.Open() + try { + $packedBridgeSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bridgeStream)).ToLowerInvariant() + } + finally { + $bridgeStream.Dispose() + } +} +finally { + $archive.Dispose() +} +$packageId = [string] $nuspec.package.metadata.id +$packageVersion = [string] $nuspec.package.metadata.version +if ($package.Name -cne "$packageId.$packageVersion.nupkg") { + throw "$($package.Name) does not match its nuspec identity $packageId $packageVersion." +} + +# Run identity. +$repository = Get-RequiredValue -Name GITHUB_REPOSITORY -Pattern '^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$' +$commit = Get-RequiredValue -Name GITHUB_SHA -Pattern $objectIdPattern +$head = Invoke-Git -Arguments @('rev-parse', 'HEAD') +if ($head -cne $commit) { + throw "The checkout HEAD is $head but GITHUB_SHA is ${commit}: build-info must describe the commit that was built." +} +$treeHash = Invoke-Git -Arguments @('rev-parse', 'HEAD^{tree}') +$eventName = Get-RequiredValue -Name GITHUB_EVENT_NAME -Pattern '^(pull_request|push|workflow_dispatch)$' +$runId = [long] (Get-RequiredValue -Name GITHUB_RUN_ID -Pattern '^[1-9][0-9]*$') +$runAttempt = [int] (Get-RequiredValue -Name GITHUB_RUN_ATTEMPT -Pattern '^[1-9][0-9]*$') +$serverUrl = Get-RequiredValue -Name GITHUB_SERVER_URL -Pattern '^https://' + +$pullRequest = $null +if ($eventName -eq 'pull_request') { + $pullRequest = [ordered]@{ + number = [int] (Get-RequiredValue -Name BUILD_INFO_PULL_REQUEST_NUMBER -Pattern '^[1-9][0-9]*$') + headSha = Get-RequiredValue -Name BUILD_INFO_PULL_REQUEST_HEAD_SHA -Pattern $objectIdPattern + } +} + +# Toolchain: the SDK that ran must be the one global.json pins. +$globalJsonPath = Join-Path $repositoryRoot 'global.json' +$globalJsonText = [IO.File]::ReadAllText($globalJsonPath).Replace("`r`n", "`n") +$globalJsonSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($globalJsonText))).ToLowerInvariant() +$pinnedSdk = ($globalJsonText | ConvertFrom-Json).sdk.version +Push-Location -LiteralPath $repositoryRoot +try { + $dotnetSdk = "$(& dotnet --version)".Trim() + if ($LASTEXITCODE -ne 0) { + throw "dotnet --version failed with exit code $LASTEXITCODE." + } +} +finally { + Pop-Location +} +if ($dotnetSdk -cne $pinnedSdk) { + throw "The build ran .NET SDK $dotnetSdk but global.json pins $pinnedSdk." +} + +$bridgeSha256 = Get-RequiredValue -Name BUILD_INFO_BRIDGE_SHA256 -Pattern $sha256Pattern +$checkedInSha256 = Get-RequiredValue -Name BUILD_INFO_CHECKED_IN_BRIDGE_SHA256 -Pattern $sha256Pattern +if ($packedBridgeSha256 -cne $bridgeSha256) { + throw "$($package.Name) packs a bridge with SHA-256 $packedBridgeSha256, but the native job built $bridgeSha256." +} + +$document = [ordered]@{ + schema = $schemaId + repository = $repository + commit = $commit + treeHash = $treeHash + ref = Get-RequiredValue -Name GITHUB_REF -Pattern '^refs/' + event = $eventName + runId = $runId + runAttempt = $runAttempt + runUrl = "$serverUrl/$repository/actions/runs/$runId" + pullRequest = $pullRequest + dotnetSdk = $dotnetSdk + globalJsonSha256 = $globalJsonSha256 + runner = [ordered]@{ + label = Get-RequiredValue -Name BUILD_INFO_RUNNER_LABEL -Pattern '^(windows-2025|ubuntu-24\.04)$' + imageOs = Get-RequiredValue -Name ImageOS + imageVersion = Get-RequiredValue -Name ImageVersion + } + toolchain = [ordered]@{ + xmake = Get-RequiredValue -Name BUILD_INFO_XMAKE_VERSION -Pattern '^\d+\.\d+\.\d+$' + msvcToolset = Get-RequiredValue -Name BUILD_INFO_MSVC_TOOLSET -Pattern '^\d+\.\d+\.\d+$' + msvcVersion = Get-RequiredValue -Name BUILD_INFO_MSVC_VERSION -Pattern '^\d+\.\d+\.\d+(\.\d+)?$' + windowsSdk = Get-RequiredValue -Name BUILD_INFO_WINDOWS_SDK_VERSION -Pattern '^\d+\.\d+\.\d+\.\d+$' + } + nativeBridge = [ordered]@{ + sha256 = $bridgeSha256 + checkedInSha256 = $checkedInSha256 + sourceFingerprint = Get-RequiredValue -Name BUILD_INFO_BRIDGE_FINGERPRINT -Pattern '^[0-9a-f]{64}:[0-9a-f]{64}$' + driftFromCheckedIn = $bridgeSha256 -ne $checkedInSha256 + } + packages = @( + [ordered]@{ + id = $packageId + version = $packageVersion + file = $package.Name + sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($packageBytes)).ToLowerInvariant() + sha512 = [Convert]::ToBase64String([Security.Cryptography.SHA512]::HashData($packageBytes)) + sbomEntry = $sbomEntry + } + ) + createdUtc = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) +} + +$json = ConvertTo-Json -InputObject $document -Depth 6 +if ($json -match '[A-Za-z]:\\\\|file://|\\\\Users\\\\') { + throw 'build-info.json would contain an absolute local path.' +} + +$output = [IO.Path]::GetFullPath($OutputPath, $PWD.Path) +New-Item -ItemType Directory -Path (Split-Path -Path $output -Parent) -Force | Out-Null +[IO.File]::WriteAllText($output, $json + "`n", [Text.UTF8Encoding]::new($false)) + +if ($env:GITHUB_STEP_SUMMARY) { + @( + '### Build info' + '' + "``build-info.json`` ($schemaId): ``$packageId`` ``$packageVersion`` from ``$commit`` (tree ``$treeHash``), .NET SDK ``$dotnetSdk``, runner ``$($document.runner.label)`` image ``$($document.runner.imageVersion)``, bridge ``$bridgeSha256``." + ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +} +Write-Information "Wrote $output." diff --git a/eng/ci/build-info.v0.schema.json b/eng/ci/build-info.v0.schema.json new file mode 100644 index 00000000..53ca7ff5 --- /dev/null +++ b/eng/ci/build-info.v0.schema.json @@ -0,0 +1,240 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/eng/ci/build-info.v0.schema.json", + "title": "CheatEngine.SDK build info v0", + "description": "Where a CheatEngine.SDK package built by CI came from: source, run, toolchain, native bridge and package hashes. Written by eng/ci/New-BuildInfo.ps1 in the Release leg of ci.yml and uploaded as the build-info artifact; precursor of the release tuple manifest.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "repository", + "commit", + "treeHash", + "ref", + "event", + "runId", + "runAttempt", + "runUrl", + "pullRequest", + "dotnetSdk", + "globalJsonSha256", + "runner", + "toolchain", + "nativeBridge", + "packages", + "createdUtc" + ], + "properties": { + "schema": { + "const": "cheatengine-build-info/v0" + }, + "repository": { + "description": "GITHUB_REPOSITORY, owner/name.", + "type": "string", + "pattern": "^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$" + }, + "commit": { + "description": "GITHUB_SHA: the commit that was built (a merge commit for pull requests).", + "$ref": "#/$defs/gitObjectId" + }, + "treeHash": { + "description": "git rev-parse HEAD^{tree}: survives squash merges, unlike the commit.", + "$ref": "#/$defs/gitObjectId" + }, + "ref": { + "type": "string", + "pattern": "^refs/" + }, + "event": { + "enum": [ + "pull_request", + "push", + "workflow_dispatch" + ] + }, + "runId": { + "type": "integer", + "minimum": 1 + }, + "runAttempt": { + "type": "integer", + "minimum": 1 + }, + "runUrl": { + "type": "string", + "pattern": "^https://" + }, + "pullRequest": { + "description": "Null outside pull_request events.", + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "number", + "headSha" + ], + "properties": { + "number": { + "type": "integer", + "minimum": 1 + }, + "headSha": { + "$ref": "#/$defs/gitObjectId" + } + } + } + ] + }, + "dotnetSdk": { + "description": "dotnet --version, equal to global.json sdk.version.", + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "globalJsonSha256": { + "description": "SHA-256 of global.json with CRLF normalized to LF (the committed blob).", + "$ref": "#/$defs/sha256" + }, + "runner": { + "type": "object", + "additionalProperties": false, + "required": [ + "label", + "imageOs", + "imageVersion" + ], + "properties": { + "label": { + "enum": [ + "windows-2025", + "ubuntu-24.04" + ] + }, + "imageOs": { + "type": "string", + "minLength": 1 + }, + "imageVersion": { + "type": "string", + "minLength": 1 + } + } + }, + "toolchain": { + "description": "The native bridge toolchain the native job resolved.", + "type": "object", + "additionalProperties": false, + "required": [ + "xmake", + "msvcToolset", + "msvcVersion", + "windowsSdk" + ], + "properties": { + "xmake": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "msvcToolset": { + "description": "VC\\Tools\\MSVC folder version, for example 14.44.35207.", + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "msvcVersion": { + "description": "cl.exe banner version, for example 19.44.35229.", + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(\\.\\d+)?$" + }, + "windowsSdk": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+\\.\\d+$" + } + } + }, + "nativeBridge": { + "type": "object", + "additionalProperties": false, + "required": [ + "sha256", + "checkedInSha256", + "sourceFingerprint", + "driftFromCheckedIn" + ], + "properties": { + "sha256": { + "description": "The CI-built bridge, which is also the file packed at build/native/cheatengine-sdk-lua-bridge.dll.", + "$ref": "#/$defs/sha256" + }, + "checkedInSha256": { + "$ref": "#/$defs/sha256" + }, + "sourceFingerprint": { + "description": ":.", + "type": "string", + "pattern": "^[0-9a-f]{64}:[0-9a-f]{64}$" + }, + "driftFromCheckedIn": { + "type": "boolean" + } + } + }, + "packages": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "file", + "sha256", + "sha512", + "sbomEntry" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "file": { + "type": "string", + "pattern": "^[^/\\\\:]+\\.nupkg$" + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "sha512": { + "description": "Standard base64 with padding, as NuGet writes contentHash.", + "type": "string", + "pattern": "^[A-Za-z0-9+/]{86}==$" + }, + "sbomEntry": { + "const": "_manifest/spdx_2.2/manifest.spdx.json" + } + } + } + }, + "createdUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + } + }, + "$defs": { + "gitObjectId": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/BuildInfoSchemaTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/BuildInfoSchemaTests.cs new file mode 100644 index 00000000..f5ecbd4d --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/BuildInfoSchemaTests.cs @@ -0,0 +1,217 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// build-info.json v0 (shared contract 1.10): the schema requires exactly the contract fields and rejects anything +/// else, eng/ci/New-BuildInfo.ps1 writes every one of them, and the Release leg writes and uploads the document. +/// +public sealed partial class BuildInfoSchemaTests +{ + private const string SchemaPath = "eng/ci/build-info.v0.schema.json"; + private const string WriterPath = "eng/ci/New-BuildInfo.ps1"; + + /// The required property lists of contract 1.10, by JSON pointer of the object schema that declares them. + private static readonly Dictionary s_requiredByObject = new(StringComparer.Ordinal) + { + [""] = + [ + "schema", "repository", "commit", "treeHash", "ref", "event", "runId", "runAttempt", "runUrl", "pullRequest", + "dotnetSdk", "globalJsonSha256", "runner", "toolchain", "nativeBridge", "packages", "createdUtc" + ], + ["/properties/pullRequest/oneOf/1"] = ["number", "headSha"], + ["/properties/runner"] = ["label", "imageOs", "imageVersion"], + ["/properties/toolchain"] = ["xmake", "msvcToolset", "msvcVersion", "windowsSdk"], + ["/properties/nativeBridge"] = ["sha256", "checkedInSha256", "sourceFingerprint", "driftFromCheckedIn"], + ["/properties/packages/items"] = ["id", "version", "file", "sha256", "sha512", "sbomEntry"] + }; + + [Fact] + public void Build_info_schema_requires_exactly_the_contract_fields() + { + using JsonDocument schema = ReadSchema(); + JsonElement root = schema.RootElement; + Assert.Equal("https://json-schema.org/draft/2020-12/schema", root.GetProperty("$schema").GetString()); + Assert.Equal($"https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/{SchemaPath}", root.GetProperty("$id").GetString()); + Assert.Equal("cheatengine-build-info/v0", root.GetProperty("properties").GetProperty("schema").GetProperty("const").GetString()); + + foreach ((string pointer, string[] required) in s_requiredByObject) + { + JsonElement node = Resolve(root, pointer); + Assert.Equal(required, Strings(node.GetProperty("required")), StringComparer.Ordinal); + // Every declared property is required: v0 has no optional field, so a missing value is always an error. + Assert.Equal(required, PropertyNames(node.GetProperty("properties")), StringComparer.Ordinal); + } + + Assert.Equal(["pull_request", "push", "workflow_dispatch"], + Strings(root.GetProperty("properties").GetProperty("event").GetProperty("enum"))); + Assert.Equal(["windows-2025", "ubuntu-24.04"], + Strings(Resolve(root, "/properties/runner/properties/label").GetProperty("enum"))); + } + + [Fact] + public void Build_info_schema_rejects_additional_properties() + { + using JsonDocument schema = ReadSchema(); + int objects = 0; + foreach (JsonElement node in ObjectSchemas(schema.RootElement)) + { + objects++; + Assert.True(node.TryGetProperty("additionalProperties", out JsonElement additional) && + additional.ValueKind == JsonValueKind.False, + $"Every object of {SchemaPath} must declare additionalProperties: false."); + } + + Assert.Equal(s_requiredByObject.Count, objects); + } + + [Fact] + public void Build_info_writer_emits_every_required_field() + { + string writer = File.ReadAllText(RepositoryFile(WriterPath)).Replace("\r\n", "\n", StringComparison.Ordinal); + foreach (string[] required in s_requiredByObject.Values) + { + foreach (string property in required) + { + Assert.True(Regex.IsMatch(writer, $@"(?m)^\s*{Regex.Escape(property)} = ", RegexOptions.None, TimeSpan.FromSeconds(1)), + $"{WriterPath} does not write '{property}'."); + } + } + + // Encoding rules of shared contract 2.0. + Assert.Contains("[Text.UTF8Encoding]::new($false)", writer, StringComparison.Ordinal); + Assert.Contains(".ToLowerInvariant()", writer, StringComparison.Ordinal); + Assert.Contains("'build-info.json would contain an absolute local path.'", writer, StringComparison.Ordinal); + } + + [Fact] + public void Release_leg_writes_and_uploads_build_info_from_the_native_job_outputs() + { + WorkflowFile pipeline = WorkflowFile.LoadWorkflow(WorkflowContract.Pipeline); + WorkflowJob job = pipeline.Job("build-test"); + YamlMappingNode step = job.Step("Write build info"); + Assert.Equal("matrix.configuration == 'Release'", WorkflowFile.Scalar(step, "if")); + Assert.True(job.StepIndex("Pack") < job.StepIndex("Write build info")); + Assert.Contains($"./{WriterPath} -PackagePath $env:PACKAGE", WorkflowFile.Scalar(step, "run"), StringComparison.Ordinal); + + // The step passes exactly the variables the writer reads, and every native-job output it names exists. + string writer = File.ReadAllText(RepositoryFile(WriterPath)); + SortedSet read = new(StringComparer.Ordinal); + foreach (Match match in BuildInfoVariable().Matches(writer)) + { + read.Add(match.Groups["name"].Value); + } + + SortedSet passed = new(StringComparer.Ordinal); + YamlMappingNode env = Assert.IsType(WorkflowFile.Mapping(step, "env")); + YamlMappingNode outputs = Assert.IsType(WorkflowFile.Mapping(pipeline.Job("native").Node, "outputs")); + foreach (KeyValuePair variable in env.Children) + { + string name = ((YamlScalarNode) variable.Key).Value ?? ""; + if (!name.StartsWith("BUILD_INFO_", StringComparison.Ordinal)) + { + continue; + } + + passed.Add(name); + Match output = NativeOutputReference().Match(((YamlScalarNode) variable.Value).Value ?? ""); + Assert.True(!output.Success || WorkflowFile.Has(outputs, output.Groups["name"].Value), + $"{name} reads the native output '{output.Groups["name"].Value}', which the native job does not declare."); + } + + Assert.Equal(read, passed); + Assert.Equal("windows-2025", WorkflowJob.Env(step, "BUILD_INFO_RUNNER_LABEL")); + Assert.Equal(job.RunsOn, WorkflowJob.Env(step, "BUILD_INFO_RUNNER_LABEL")); + + YamlMappingNode upload = Assert.Single(job.StepsUsing("actions/upload-artifact@"), + static candidate => string.Equals(WorkflowJob.With(candidate, "name"), "build-info", StringComparison.Ordinal)); + Assert.Equal("matrix.configuration == 'Release'", WorkflowFile.Scalar(upload, "if")); + Assert.Equal("artifacts/build-info/build-info.json", WorkflowJob.With(upload, "path")); + } + + private static JsonDocument ReadSchema() + { + return JsonDocument.Parse(File.ReadAllText(RepositoryFile(SchemaPath))); + } + + private static string RepositoryFile(string relativePath) + { + string path = Path.Combine(RepositoryRoot.Path, relativePath); + Assert.True(File.Exists(path), $"{relativePath} is missing."); + return path; + } + + private static JsonElement Resolve(JsonElement root, string pointer) + { + JsonElement node = root; + foreach (string segment in pointer.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + node = node.ValueKind == JsonValueKind.Array + ? node[int.Parse(segment, System.Globalization.CultureInfo.InvariantCulture)] + : node.GetProperty(segment); + } + + return node; + } + + private static List Strings(JsonElement array) + { + List values = []; + foreach (JsonElement item in array.EnumerateArray()) + { + values.Add(item.GetString() ?? ""); + } + + return values; + } + + private static List PropertyNames(JsonElement properties) + { + List names = []; + foreach (JsonProperty property in properties.EnumerateObject()) + { + names.Add(property.Name); + } + + return names; + } + + /// Every schema node that declares "type": "object", at any depth. + private static List ObjectSchemas(JsonElement node) + { + List found = []; + if (node.ValueKind == JsonValueKind.Object) + { + if (node.TryGetProperty("type", out JsonElement type) && type.ValueKind == JsonValueKind.String && + string.Equals(type.GetString(), "object", StringComparison.Ordinal)) + { + found.Add(node); + } + + foreach (JsonProperty property in node.EnumerateObject()) + { + found.AddRange(ObjectSchemas(property.Value)); + } + } + else if (node.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in node.EnumerateArray()) + { + found.AddRange(ObjectSchemas(item)); + } + } + + return found; + } + + [GeneratedRegex(@"-Name (?BUILD_INFO_[A-Z0-9_]+)", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex BuildInfoVariable(); + + [GeneratedRegex(@"^\$\{\{ needs\.native\.outputs\.(?[a-z0-9-]+) \}\}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex NativeOutputReference(); +} From 2466b3dc42a4598e6587e8a272d983063159e22e Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:52:09 +0200 Subject: [PATCH 054/199] Add zizmor, PSScriptAnalyzer and format checks The lint job only ran actionlint over a sparse .github checkout: workflow security findings, PowerShell defects and unformatted C# outside the solution reached main unnoticed (audit register PR-CQ-18, 49, 58). - lint (named Lint, full checkout) keeps the checksum-verified actionlint 1.7.12 and adds zizmor through zizmorcore/zizmor-action pinned by commit and by tool version 1.30.1, offline audits only, without advanced security (no security-events permission, works on forks), configured by .github/zizmor.yml. Its two entries carry their reasons: a temporary dependabot-cooldown ignore until Dependabot gains a cooldown, and self-repository disabled because the pinned actionlint rejects the suggested `uses: $/...` syntax. - eng/ci/Invoke-ScriptAnalysis.ps1 downloads PSScriptAnalyzer 1.25.0 from the PowerShell Gallery, verifies its SHA-256, imports it from a scratch folder and analyses every *.ps1 and *.psm1 known to git with eng/PSScriptAnalyzerSettings.psd1 (Error and Warning; Write-Host allowed for workflow commands). Every Error or Warning record fails, except an allowlist entry naming the file and rule with its reason; a stale entry fails too. It does not rely on -EnableExit, which counts errors only. - A new format job runs `dotnet format whitespace . --folder --verify-no-changes --exclude artifacts` on Ubuntu without a restore, valid because the checkout is CRLF as .editorconfig declares; the gate now needs it. Style rules stay enforced by the build (EnforceCodeStyleInBuild with warnings as errors). Tests pin the linters by version and checksum, require a reason for every zizmor exception (including inline composite-action suppressions), and freeze the format command. dotnet format: https://learn.microsoft.com/dotnet/core/tools/dotnet-format Manual PowerShell Gallery download: https://learn.microsoft.com/powershell/gallery/how-to/working-with-packages/manual-download Invoke-ScriptAnalyzer: https://learn.microsoft.com/powershell/module/psscriptanalyzer/invoke-scriptanalyzer --- .github/workflows/ci.yml | 46 +++++- .github/zizmor.yml | 17 +++ CheatEngine.SDK.slnx | 3 + eng/PSScriptAnalyzerSettings.psd1 | 10 ++ eng/ci/Invoke-ScriptAnalysis.ps1 | 127 ++++++++++++++++ .../Workflows/WorkflowContractTests.Lint.cs | 140 ++++++++++++++++++ 6 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 .github/zizmor.yml create mode 100644 eng/PSScriptAnalyzerSettings.psd1 create mode 100644 eng/ci/Invoke-ScriptAnalysis.ps1 create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Lint.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a498d0b..588480c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -463,17 +463,16 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} lint: - name: Lint workflows + name: Lint runs-on: ubuntu-24.04 timeout-minutes: 10 env: ACTIONLINT_VERSION: 1.7.12 ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 # linux_amd64, official checksums file steps: - - name: Checkout workflow definitions + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - sparse-checkout: .github persist-credentials: false - name: Run actionlint @@ -495,11 +494,50 @@ jobs: throw "actionlint failed with exit code $LASTEXITCODE." } + # Offline audits only: the gate must not depend on the GitHub API. The online audits run in an advisory workflow. + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + online-audits: false + advanced-security: false + annotations: true + config: .github/zizmor.yml + + - name: Run PSScriptAnalyzer + run: | + ./eng/ci/Invoke-ScriptAnalysis.ps1 -ModuleDirectory (Join-Path $env:RUNNER_TEMP 'psscriptanalyzer') + if ($LASTEXITCODE -ne 0) { + throw "PSScriptAnalyzer failed with exit code $LASTEXITCODE." + } + + # Whitespace formatting of every C# file, including projects outside the solution. Style rules are enforced by the + # build itself (EnforceCodeStyleInBuild with warnings as errors). No restore: --folder needs no MSBuild workspace. + format: + name: Format + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Verify whitespace formatting + run: | + dotnet format whitespace . --folder --verify-no-changes --exclude artifacts + if ($LASTEXITCODE -ne 0) { + throw "Whitespace formatting differs (exit code $LASTEXITCODE). Run 'dotnet format whitespace . --folder --exclude artifacts' and commit the result." + } + gate: name: Gate # always(): a failed or cancelled job must turn the required check red instead of skipping it. if: always() - needs: [ native, build-test, aot, sonar, lint ] + needs: [ native, build-test, aot, sonar, lint, format ] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: { } diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000..96963811 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,17 @@ +# zizmor configuration: the lint job of ci.yml passes it explicitly (offline audits, pinned zizmor version), and a +# local `zizmor --offline .github` discovers it. Every entry below ignores or disables one audit, with the reason in +# the comment directly above it; WorkflowContractTests fails on an entry without one. Findings in composite actions +# cannot be ignored here: they carry an inline `# zizmor: ignore[] ` comment instead. +# https://docs.zizmor.sh/configuration/ +rules: + # Temporary. .github/dependabot.yml has no `cooldown` yet; the Dependabot hardening of the audit remediation adds it. + # Remove this entry in the commit that adds a cooldown to both update blocks. + dependabot-cooldown: + ignore: + - dependabot.yml + + # zizmor suggests GitHub's `uses: $/` self-repository syntax for local actions and reusable workflows, but the + # pinned actionlint 1.7.12 rejects it ("invalid format because ref is missing"). Keep the documented `./` form + # until the pinned actionlint accepts the new syntax. + self-repository: + disable: true diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 630c35ad..c32d8bea 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -16,6 +16,7 @@ + @@ -34,6 +35,7 @@ + @@ -51,6 +53,7 @@ + diff --git a/eng/PSScriptAnalyzerSettings.psd1 b/eng/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 00000000..36a4fdf5 --- /dev/null +++ b/eng/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,10 @@ +@{ + # The lint profile of every tracked *.ps1, applied by eng/ci/Invoke-ScriptAnalysis.ps1 in the CI lint job and locally. + # Error and Warning records fail the job unless the script allowlists them for one file with a reason. + Severity = @('Error', 'Warning') + + ExcludeRules = @( + # CI scripts report through GitHub workflow commands (::error::, ::notice::), which are host output by design. + 'PSAvoidUsingWriteHost' + ) +} diff --git a/eng/ci/Invoke-ScriptAnalysis.ps1 b/eng/ci/Invoke-ScriptAnalysis.ps1 new file mode 100644 index 00000000..1ac75a39 --- /dev/null +++ b/eng/ci/Invoke-ScriptAnalysis.ps1 @@ -0,0 +1,127 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs a pinned, hash-verified PSScriptAnalyzer over every PowerShell script of the repository. + +.DESCRIPTION + The CI lint job and developers run the same analysis: + 1. PSScriptAnalyzer $moduleVersion is downloaded from the PowerShell Gallery (a nupkg), its SHA-256 is compared + with the pinned value, and the verified package is expanded and imported from -ModuleDirectory. Nothing is + installed into a PowerShell module path. + https://learn.microsoft.com/powershell/gallery/how-to/working-with-packages/manual-download + 2. Every *.ps1 script and *.psm1 module known to git (tracked, plus untracked non-ignored files) is analyzed with + eng/PSScriptAnalyzerSettings.psd1. + 3. Every Error, Warning or ParseError record fails the run, unless the allowlist below names that file and rule + with a reason. An allowlist entry that no longer matches a record also fails, so the list only shrinks. + -EnableExit is not used: it counts error records only. + https://learn.microsoft.com/powershell/module/psscriptanalyzer/invoke-scriptanalyzer + +.PARAMETER ModuleDirectory + Where the verified PSScriptAnalyzer package is downloaded and expanded. Reused when it already holds the pinned + package with the pinned hash. + +.PARAMETER Settings + The settings file. + +.EXAMPLE + ./eng/ci/Invoke-ScriptAnalysis.ps1 -ModuleDirectory $env:TEMP/psscriptanalyzer +#> +[CmdletBinding()] +param( + [string] $ModuleDirectory = (Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) 'psscriptanalyzer'), + + [string] $Settings = 'eng/PSScriptAnalyzerSettings.psd1' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$InformationPreference = 'Continue' + +$moduleVersion = '1.25.0' +# SHA-256 of https://www.powershellgallery.com/api/v2/package/PSScriptAnalyzer/1.25.0 (14 658 674 bytes; the gallery's +# SHA-512 5/tXMmLmBLqymRSuYpIdJLl8L4i1GbQSd7QD72zhY/FLfRs23HNEmmjlrlqNlQKJGxDCZBMf0YE63ym/ExwWYw== matches it). +$moduleSha256 = '14e634c828eb98efb9f40b2918ba90f139ed5eccdf663a2a747736d996995d60' + +# Findings accepted for one file, each with the reason and the work that removes it. +$allowlist = @( + [pscustomobject] @{ + Path = 'eng/lua-bridge/Test-ProtectedOperationCatalog.ps1' + Rule = 'PSUseApprovedVerbs' + Reason = 'Require-Property and Escape-Regex go away when the protected-operation catalogue script is rebuilt (audit A20-Q13).' + } +) + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$moduleRoot = [IO.Path]::GetFullPath($ModuleDirectory, $PWD.Path) +$package = Join-Path $moduleRoot "PSScriptAnalyzer.$moduleVersion.nupkg" +$expanded = Join-Path $moduleRoot "PSScriptAnalyzer/$moduleVersion" +$manifest = Join-Path $expanded 'PSScriptAnalyzer.psd1' + +New-Item -ItemType Directory -Path $moduleRoot -Force | Out-Null +if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { + $uri = "https://www.powershellgallery.com/api/v2/package/PSScriptAnalyzer/$moduleVersion" + Invoke-WebRequest -Uri $uri -OutFile $package -MaximumRetryCount 3 -RetryIntervalSec 5 +} +$actualSha256 = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actualSha256 -cne $moduleSha256) { + Remove-Item -LiteralPath $package -Force + throw "PSScriptAnalyzer $moduleVersion has SHA-256 $actualSha256, expected $moduleSha256." +} +if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + Expand-Archive -LiteralPath $package -DestinationPath $expanded -Force +} +Import-Module -Name $manifest -Force +$loaded = Get-Module -Name PSScriptAnalyzer +if ($null -eq $loaded -or $loaded.Version -ne [version] $moduleVersion) { + throw "Expected PSScriptAnalyzer $moduleVersion to be loaded from '$manifest'." +} + +$settingsPath = [IO.Path]::GetFullPath($Settings, $repositoryRoot) +$scripts = @(& git -C $repositoryRoot ls-files --cached --others --exclude-standard -- '*.ps1' '*.psm1') +if ($LASTEXITCODE -ne 0) { + throw "git ls-files failed with exit code $LASTEXITCODE." +} +$scripts = @($scripts | Sort-Object -Unique) +if ($scripts.Count -eq 0) { + throw "No *.ps1 or *.psm1 file found under '$repositoryRoot'." +} + +$failures = [Collections.Generic.List[string]]::new() +$allowed = [Collections.Generic.List[string]]::new() +$usedAllowlist = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($script in $scripts) { + $records = @(Invoke-ScriptAnalyzer -Path (Join-Path $repositoryRoot $script) -Settings $settingsPath) + foreach ($record in $records) { + if ("$($record.Severity)" -notin @('Error', 'Warning', 'ParseError')) { + continue + } + $location = "${script}:$($record.Line)" + $entry = $allowlist | Where-Object { $_.Path -ceq $script -and $_.Rule -ceq $record.RuleName } | Select-Object -First 1 + if ($null -ne $entry) { + [void] $usedAllowlist.Add("$($entry.Path)|$($entry.Rule)") + $allowed.Add("$location $($record.RuleName): allowlisted ($($entry.Reason))") + continue + } + $failures.Add("$location $($record.RuleName) [$($record.Severity)]: $($record.Message)") + Write-Host "::error file=$script,line=$($record.Line),title=$($record.RuleName)::$($record.Message)" + } +} +foreach ($entry in $allowlist) { + if (-not $usedAllowlist.Contains("$($entry.Path)|$($entry.Rule)")) { + $failures.Add("Allowlist entry $($entry.Path) $($entry.Rule) no longer matches a finding: remove it from eng/ci/Invoke-ScriptAnalysis.ps1.") + } +} + +$allowed | ForEach-Object { Write-Information $_ } +if ($env:GITHUB_STEP_SUMMARY) { + @( + '### PSScriptAnalyzer' + '' + "PSScriptAnalyzer $moduleVersion over $($scripts.Count) scripts: $($failures.Count) failing and $($allowed.Count) allowlisted records." + ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +} +if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Information $_ } + throw "PSScriptAnalyzer reported $($failures.Count) finding(s) in $($scripts.Count) scripts." +} +Write-Information "PSScriptAnalyzer $moduleVersion`: $($scripts.Count) scripts clean ($($allowed.Count) allowlisted records)." diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Lint.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Lint.cs new file mode 100644 index 00000000..71e196e8 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Lint.cs @@ -0,0 +1,140 @@ +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// The lint and format jobs: actionlint, zizmor and PSScriptAnalyzer pinned and verified, whitespace verified. +public sealed partial class WorkflowContractTests +{ + private const string ZizmorConfig = ".github/zizmor.yml"; + private const string ScriptAnalysis = "eng/ci/Invoke-ScriptAnalysis.ps1"; + private const string ScriptAnalysisSettings = "eng/PSScriptAnalyzerSettings.psd1"; + + [Fact] + public void Lint_job_checks_out_the_repository_and_runs_every_linter() + { + WorkflowJob lint = Pipeline().Job("lint"); + + // Scripts live under eng/ and tests/: the whole tree is checked out, not a sparse .github. + YamlMappingNode checkout = Assert.Single(lint.StepsUsing("actions/checkout@")); + Assert.Null(WorkflowJob.With(checkout, "sparse-checkout")); + Assert.Empty(lint.Needs()); + foreach (string step in new[] { "Run actionlint", "Run zizmor", "Run PSScriptAnalyzer" }) + { + Assert.True(lint.StepIndex(step) >= 0, $"The lint job has no '{step}' step."); + } + } + + [Fact] + public void Zizmor_and_actionlint_are_pinned_by_version_and_checksum() + { + WorkflowJob lint = Pipeline().Job("lint"); + YamlMappingNode env = Assert.IsType(WorkflowFile.Mapping(lint.Node, "env")); + Assert.Matches(ExactVersion(), WorkflowFile.Scalar(env, "ACTIONLINT_VERSION") ?? ""); + Assert.Matches(Sha256(), WorkflowFile.Scalar(env, "ACTIONLINT_SHA256") ?? ""); + string actionlint = WorkflowFile.Scalar(lint.Step("Run actionlint"), "run") ?? ""; + Assert.Contains("(Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()", actionlint, + StringComparison.Ordinal); + Assert.Contains("if ($actual -ne $env:ACTIONLINT_SHA256)", actionlint, StringComparison.Ordinal); + + // The action commit and the zizmor version are both pinned, and the gate never depends on the GitHub API. + YamlMappingNode zizmor = Assert.Single(lint.StepsUsing("zizmorcore/zizmor-action@")); + Assert.Matches(ExactVersion(), WorkflowJob.With(zizmor, "version") ?? ""); + Assert.Equal("false", WorkflowJob.With(zizmor, "online-audits")); + Assert.Equal("false", WorkflowJob.With(zizmor, "advanced-security")); + Assert.Equal(ZizmorConfig, WorkflowJob.With(zizmor, "config")); + Assert.Null(WorkflowJob.With(zizmor, "persona")); + Assert.Null(WorkflowJob.With(zizmor, "min-severity")); + Assert.NotNull(ReadRepositoryText(ZizmorConfig)); + } + + [Fact] + public void Every_zizmor_exception_carries_a_justification_comment() + { + string[] lines = ReadRepositoryText(ZizmorConfig).Split('\n'); + YamlStream stream = []; + stream.Load(new StringReader(string.Join('\n', lines))); + YamlMappingNode rules = Assert.IsType( + WorkflowFile.Mapping(Assert.IsType(stream.Documents[0].RootNode), "rules")); + foreach (KeyValuePair rule in rules.Children) + { + YamlMappingNode body = Assert.IsType(rule.Value); + Assert.True(WorkflowFile.Has(body, "ignore") || WorkflowFile.Has(body, "disable"), + $"{ZizmorConfig} rule '{rule.Key}' neither ignores nor disables anything."); + int line = (int) rule.Key.Start.Line - 2; + while (line >= 0 && string.IsNullOrWhiteSpace(lines[line])) + { + line--; + } + + Assert.True(line >= 0 && lines[line].TrimStart().StartsWith('#'), + $"{ZizmorConfig} rule '{rule.Key}' needs a comment directly above it giving the reason."); + } + + // Inline suppressions (the only kind a composite action supports) carry their reason on the same comment. + foreach (WorkflowFile file in AllActionFiles()) + { + foreach (Match ignore in InlineZizmorIgnore().Matches(file.Text)) + { + Assert.True(ignore.Groups["reason"].Value.Trim().Length >= 20, + $"{file.RelativePath}: '{ignore.Value.Trim()}' must explain why the finding is acceptable."); + } + } + } + + [Fact] + public void Script_analysis_uses_a_pinned_hash_verified_psscriptanalyzer() + { + string script = ReadRepositoryText(ScriptAnalysis); + Assert.Matches(PinnedModuleVersion(), script); + Assert.Matches(PinnedModuleHash(), script); + Assert.Contains("if ($actualSha256 -cne $moduleSha256)", script, StringComparison.Ordinal); + Assert.Contains("Import-Module -Name $manifest -Force", script, StringComparison.Ordinal); + Assert.Contains("git -C $repositoryRoot ls-files --cached --others --exclude-standard -- '*.ps1' '*.psm1'", script, + StringComparison.Ordinal); + // -EnableExit counts error records only; the script fails on warnings itself. + Assert.DoesNotContain("-EnableExit", StripComments(script), StringComparison.Ordinal); + + string settings = ReadRepositoryText(ScriptAnalysisSettings); + Assert.Contains("Severity = @('Error', 'Warning')", settings, StringComparison.Ordinal); + + string run = WorkflowFile.Scalar(Pipeline().Job("lint").Step("Run PSScriptAnalyzer"), "run") ?? ""; + Assert.Contains($"./{ScriptAnalysis}", run, StringComparison.Ordinal); + } + + [Fact] + public void Format_job_verifies_whitespace_without_restore() + { + WorkflowJob format = Pipeline().Job("format"); + Assert.Empty(format.Needs()); + + // The pinned SDK comes from the composite action; --folder needs no restore and no MSBuild workspace. + YamlMappingNode setup = Assert.Single(format.StepsUsing(WorkflowContract.SetupAction)); + Assert.Null(WorkflowJob.With(setup, "restore")); + string run = format.RunText(); + Assert.Contains("dotnet format whitespace . --folder --verify-no-changes --exclude artifacts", run, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet restore", run, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet build", run, StringComparison.Ordinal); + // Style rules are enforced by the build (EnforceCodeStyleInBuild, warnings as errors), not by this job. + Assert.DoesNotContain("format style", run, StringComparison.Ordinal); + + // Valid on Linux only because the checkout is CRLF and .editorconfig says so. + Assert.Contains("end_of_line = crlf", ReadRepositoryText(".editorconfig"), StringComparison.Ordinal); + } + + [GeneratedRegex(@"#\s*zizmor:\s*ignore\[[^\]]+\](?[^\n]*)", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex InlineZizmorIgnore(); + + [GeneratedRegex(@"^\d+\.\d+\.\d+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ExactVersion(); + + [GeneratedRegex(@"^[0-9a-f]{64}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Sha256(); + + [GeneratedRegex(@"(?m)^\$moduleVersion = '\d+\.\d+\.\d+'$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PinnedModuleVersion(); + + [GeneratedRegex(@"(?m)^\$moduleSha256 = '[0-9a-f]{64}'$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PinnedModuleHash(); +} From 173813bb5954cea8bcf3f2977edab2febcbb3b7e Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:52:38 +0200 Subject: [PATCH 055/199] Review dependencies and verify lock files in CI Dependency review had been removed and nothing checked that the committed packages.lock.json files still matched what a fresh restore produces (audit register PR-CQ-43, PR-CQ-05 CI side). - A dependency-review job always runs, so the gate can require success on every event: on pull requests it runs actions/dependency-review-action v5 with .github/dependency-review-config.yml, comment-summary-in-pr: never (read-only token, same behaviour on forks) and retry-on-snapshot-warnings; on other events it records a notice. The configuration fails on moderate or higher advisories in runtime and development scopes, allows MIT, Apache-2.0, BSD, ISC, 0BSD, Unlicense and MS-PL, and lists the two Microsoft coverage packages whose nuspec ships a license file instead of an SPDX expression (every direct reference was checked on nuget.org). Options the workflow sets inline are not repeated in the file. - A lock-files job runs ./eng/Update-LockFiles.ps1 -Verify on windows-2025, the only host that reproduces the win-x64 ILCompiler sections of the Native AOT locks. Both jobs join gate.needs. Tests freeze the always-running review, its configuration and the verification job. --- .github/dependency-review-config.yml | 37 +++++++++ .github/workflows/ci.yml | 50 +++++++++++- CheatEngine.SDK.slnx | 1 + .../Workflows/WorkflowContractTests.Supply.cs | 78 +++++++++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 .github/dependency-review-config.yml create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Supply.cs diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml new file mode 100644 index 00000000..6cc59f70 --- /dev/null +++ b/.github/dependency-review-config.yml @@ -0,0 +1,37 @@ +# Configuration of actions/dependency-review-action v5, run by the dependency-review job of ci.yml on pull requests. +# The job passes config-file, comment-summary-in-pr: never (no pull-requests: write, so it works the same on forks) and +# retry-on-snapshot-warnings: true inline, as shared contract 1.6 freezes them; every other option lives here. +# Option names: https://github.com/actions/dependency-review-action#configuration-options + +# A pull request fails when it adds a dependency with a known vulnerability of this severity or higher, in any scope: +# test and build tooling runs in CI with the repository checkout, so development dependencies count too. +fail-on-severity: moderate +fail-on-scopes: + - runtime + - development + +# Licenses a new or updated dependency may carry (SPDX identifiers). Every direct reference was MIT or Apache-2.0 when +# this list was written (nuget.org nuspecs, 2026-09-23). +allow-licenses: + - MIT + - Apache-2.0 + - BSD-2-Clause + - BSD-3-Clause + - ISC + - 0BSD + - Unlicense + - MS-PL + +# Packages whose nuspec ships the license as a file (license type="file") instead of an SPDX expression, so the +# dependency graph has no identifier to compare. Both are Microsoft's code coverage tooling, used only by test runs and +# never shipped in the CheatEngine.SDK package. Add a package here only with the same verification and a reason. +allow-dependencies-licenses: + - pkg:nuget/Microsoft.Testing.Extensions.CodeCoverage + - pkg:nuget/dotnet-coverage + +# Without automatic dependency submission the head snapshot of a pull request can lag: wait up to two minutes for it +# (retry-on-snapshot-warnings itself is set in the workflow) instead of reviewing a partial graph. +retry-on-snapshot-warnings-timeout: 120 + +show-openssf-scorecard: true +warn-on-openssf-scorecard-level: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 588480c0..25c438fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -533,11 +533,59 @@ jobs: throw "Whitespace formatting differs (exit code $LASTEXITCODE). Run 'dotnet format whitespace . --folder --exclude artifacts' and commit the result." } + # Always runs, so the gate never sees it skipped: pull requests are reviewed, every other event records a notice. + dependency-review: + name: Dependency review + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + if: github.event_name == 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Review dependency changes + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + config-file: ./.github/dependency-review-config.yml + comment-summary-in-pr: never + retry-on-snapshot-warnings: true + + - name: Nothing to review + if: github.event_name != 'pull_request' + env: + EVENT: ${{ github.event_name }} + run: Write-Host "::notice title=Dependency review::A $env:EVENT run has no base to compare; dependency review runs on pull requests." + + # The committed packages.lock.json files must be exactly what a fresh restore produces. Windows only: the Native AOT + # lock sections record the host-RID ILCompiler packages. + lock-files: + name: Lock files + runs-on: windows-2025 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Verify lock files + run: | + ./eng/Update-LockFiles.ps1 -Verify + if ($LASTEXITCODE -ne 0) { + throw "Lock files differ from a fresh restore (exit code $LASTEXITCODE). Run ./eng/Update-LockFiles.ps1 and commit the listed files." + } + gate: name: Gate # always(): a failed or cancelled job must turn the required check red instead of skipping it. if: always() - needs: [ native, build-test, aot, sonar, lint, format ] + needs: [ native, build-test, aot, sonar, lint, format, dependency-review, lock-files ] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: { } diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index c32d8bea..dc31aaab 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -16,6 +16,7 @@ + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Supply.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Supply.cs new file mode 100644 index 00000000..8ab6cc06 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Supply.cs @@ -0,0 +1,78 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// Supply-chain jobs of ci.yml: the dependency review that never skips and the lock-file drift check. +public sealed partial class WorkflowContractTests +{ + private const string DependencyReviewConfig = ".github/dependency-review-config.yml"; + + [Fact] + public void Dependency_review_job_always_runs_and_reviews_only_pull_requests() + { + WorkflowJob job = Pipeline().Job("dependency-review"); + + // The gate requires success: the job itself never skips, only its steps choose by event (contract 1.11). + Assert.Null(job.Condition); + Assert.Empty(job.Needs()); + Assert.False(WorkflowFile.Has(job.Node, "permissions"), "dependency-review keeps the read-only top-level permissions."); + + YamlMappingNode review = Assert.Single(job.StepsUsing("actions/dependency-review-action@")); + Assert.Equal("github.event_name == 'pull_request'", WorkflowFile.Scalar(review, "if")); + Assert.Equal($"./{DependencyReviewConfig}", WorkflowJob.With(review, "config-file")); + Assert.Equal("never", WorkflowJob.With(review, "comment-summary-in-pr")); + Assert.Equal("true", WorkflowJob.With(review, "retry-on-snapshot-warnings")); + + YamlMappingNode notice = job.Step("Nothing to review"); + Assert.Equal("github.event_name != 'pull_request'", WorkflowFile.Scalar(notice, "if")); + Assert.Contains("::notice", WorkflowFile.Scalar(notice, "run"), StringComparison.Ordinal); + + // Every other step serves the review and runs on pull requests only. + foreach (YamlMappingNode step in job.Steps) + { + string? condition = WorkflowFile.Scalar(step, "if"); + Assert.True(condition is "github.event_name == 'pull_request'" or "github.event_name != 'pull_request'", + $"{job.Location} step '{WorkflowFile.Scalar(step, "name")}' must choose by event, never skip the job."); + } + } + + [Fact] + public void Dependency_review_configuration_blocks_advisories_and_unreviewed_licenses() + { + YamlStream stream = []; + stream.Load(new StringReader(ReadRepositoryText(DependencyReviewConfig))); + YamlMappingNode config = Assert.IsType(stream.Documents[0].RootNode); + + Assert.Equal("moderate", WorkflowFile.Scalar(config, "fail-on-severity")); + Assert.Equal(["runtime", "development"], + WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(config, "fail-on-scopes")))); + Assert.Contains("MIT", + WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(config, "allow-licenses"))), + StringComparer.Ordinal); + foreach (string purl in WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(config, "allow-dependencies-licenses")))) + { + Assert.StartsWith("pkg:nuget/", purl, StringComparison.Ordinal); + } + + // The workflow sets these inline (contract 1.6); a second value here could silently disagree. + foreach (string inline in new[] { "comment-summary-in-pr", "retry-on-snapshot-warnings", "config-file" }) + { + Assert.False(WorkflowFile.Has(config, inline), $"{DependencyReviewConfig} must not repeat '{inline}', which ci.yml sets."); + } + } + + [Fact] + public void Lock_file_job_runs_the_verification_script_on_windows() + { + WorkflowJob job = Pipeline().Job("lock-files"); + + // Native AOT lock sections record the host-RID ILCompiler packages: only a Windows restore reproduces them. + Assert.Equal("windows-2025", job.RunsOn); + Assert.Empty(job.Needs()); + YamlMappingNode setup = Assert.Single(job.StepsUsing(WorkflowContract.SetupAction)); + Assert.Null(WorkflowJob.With(setup, "restore")); + string run = job.RunText(); + Assert.Contains("./eng/Update-LockFiles.ps1 -Verify", run, StringComparison.Ordinal); + Assert.Contains("[switch] $Verify", ReadRepositoryText("eng/Update-LockFiles.ps1"), StringComparison.Ordinal); + } +} From 96b6b12cbd90f23e604b5d0b073d26932e6e644c Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:53:17 +0200 Subject: [PATCH 056/199] Require Sonar through the Gate expectation The gate accepted a skipped Sonar on every event, so a broken condition or a missing analysis never turned CI / Gate red, and main-ci listened to a merge_group event that no merge queue produces (audit register PR-CQ-01, PR-CQ-03, PR-CQ-47; Sonar is mandatory by maintainer decision). - jobs.sonar.if is the SONAR_EXPECTED expression of shared contract 1.7: sonar requested, not merge_group (dormant defense in depth), actor not Dependabot (github.actor on purpose: a maintainer who pushes onto a Dependabot branch has the token and is analysed), and not a fork pull request. The quality gate is awaited on every event except push. - The gate receives the same expression as SONAR_EXPECTED and decides from one required result per job: success everywhere, except sonar, which must be success when SONAR_EXPECTED is true and skipped when it is false; a skipped job anywhere else, or a Sonar run nobody expected, fails it. The step summary lists job, result, required result and reason. - main-ci.yml drops the merge_group trigger and keeps no concurrency group, so every main commit keeps its own run. - sonar.yml excludes QualificationTarget, native-host-emulator, eng/tools and docs from analysis and docs from coverage; tests stay analysed, and New Code of other branches is measured against main (a SonarQube Cloud setting). Tests freeze the callers (job ci named CI in pull-request-ci, main-ci and release), the gate (named Gate, always(), no permissions, every other job in needs but the advisory client-canary), the ci.yml job table, inputs and secret, textual equality of the two SONAR_EXPECTED copies, the triggers (no pull_request_target, no merge_group, no path filter) and the Sonar exclusions. A local run of the gate script over eleven synthetic results of contract 1.11 gave the expected verdict each time. --- .github/workflows/ci.yml | 101 ++++-- .github/workflows/main-ci.yml | 8 +- .github/workflows/pull-request-ci.yml | 3 +- .github/workflows/sonar.yml | 18 +- .../Workflows/WorkflowContractTests.Gate.cs | 297 ++++++++++++++++++ 5 files changed, 387 insertions(+), 40 deletions(-) create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25c438fd..c0f11c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,19 @@ name: CI -# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. +# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. Its gate job is the required check +# "CI / Gate"; the caller job id `ci` (name CI) and the job name Gate are frozen. # -# native ─► build-test (Debug, Release) ─► sonar ─┐ -# └► aot ─────────────────────────────────┼─► gate (the only required check: "CI / Gate") -# lint ──────────────────────────────────────────┘ +# native ─┬─► build-test (Debug, Release) ─► sonar ─┐ +# └─► aot ───────────────────────────────────┤ +# lint, format, dependency-review, lock-files ───────┴─► gate # -# Jobs never rebuild what an upstream job produced; they exchange artifacts: +# Artifacts (the only names this workflow may upload; WorkflowContractTests holds the list): # lua-protection-bridge, classic-abi-fixture-facts native → build-test, aot +# nuget-package, build-info build-test (Release) → sonar, release.yml, reviewers # coverage build-test (Debug) → sonar -# nuget-package build-test (Release) → sonar, release publish, reviewers -# test-results- build-test → humans +# coverage-report build-test (Debug) → reviewers +# test-results- build-test → reviewers +# test-dumps-, binlogs-[-] on failure only # # No job restores or saves a NuGet cache: every job is reachable from the tag release run. Every restore is locked # against the committed packages.lock.json files (.github/actions/setup-dotnet). @@ -19,7 +22,9 @@ on: workflow_call: inputs: sonar: - description: Run the SonarQube Cloud analysis. Merge-queue runs, fork pull requests and Dependabot always skip it. + description: >- + Run the SonarQube Cloud analysis. The gate then requires it (SONAR_EXPECTED), except for fork pull requests + and Dependabot, which receive no secrets. type: boolean default: false package-version: @@ -446,19 +451,21 @@ jobs: if-no-files-found: ignore retention-days: 5 - # Secrets never reach fork or Dependabot runs, and a merge-queue branch is analysed again once it lands on main. - # Pull requests fail on the quality gate; main only reports it. + # SONAR_EXPECTED: the same expression is the gate's SONAR_EXPECTED variable (env is not available in jobs..if; + # WorkflowContractTests keeps the two texts equal). Secrets never reach fork or Dependabot runs, the dormant merge_group + # clause stays as defense in depth, and release.yml never passes sonar. github.actor is deliberate: a maintainer who + # pushes onto a Dependabot branch has the token and must be analysed. Outside push events the quality gate is awaited. sonar: name: Sonar needs: build-test if: >- - inputs.sonar - && github.event_name != 'merge_group' - && github.actor != 'dependabot[bot]' - && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + ${{ inputs.sonar + && github.event_name != 'merge_group' + && github.actor != 'dependabot[bot]' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} uses: ./.github/workflows/sonar.yml with: - wait-quality-gate: ${{ github.event_name == 'pull_request' }} + wait-quality-gate: ${{ github.event_name != 'push' }} secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -593,20 +600,58 @@ jobs: - name: Check results env: NEEDS: ${{ toJSON(needs) }} + SONAR_EXPECTED: >- + ${{ inputs.sonar + && github.event_name != 'merge_group' + && github.actor != 'dependabot[bot]' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + EVENT: ${{ github.event_name }} run: | - # Every job must succeed. Only sonar may be skipped: its own condition turns it off for releases, the merge - # queue, forks and Dependabot. A sonar skipped because build-test failed is caught by build-test's result. - $mayBeSkipped = @('sonar') + # Every job must succeed, except sonar, which must match SONAR_EXPECTED: success when it is true, skipped when it + # is false (fork, Dependabot, merge_group or release run). A sonar run that was not expected means the job + # condition and this expression drifted apart, which fails the gate too. No other job may ever be skipped. + $ErrorActionPreference = 'Stop' + if ($env:SONAR_EXPECTED -notin @('true', 'false')) { + Write-Host "::error title=Gate::SONAR_EXPECTED must be 'true' or 'false', got '$env:SONAR_EXPECTED'." + exit 1 + } + $sonarExpected = $env:SONAR_EXPECTED -eq 'true' $needs = $env:NEEDS | ConvertFrom-Json -AsHashtable - $failed = [Collections.Generic.List[string]]::new() - $rows = foreach ($job in @($needs.Keys | Sort-Object)) { - $result = $needs[$job].result - $allowed = if ($job -in $mayBeSkipped) { 'success', 'skipped' } else { 'success' } - if ($result -notin $allowed) { $failed.Add("$job ($result)") } - "| $job | $result |" - } - @('| Job | Result |', '| --- | --- |') + $rows | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - if ($failed.Count -gt 0) { - Write-Host "::error::Not successful: $($failed -join ', ')." + if ($needs.Count -eq 0) { + Write-Host '::error title=Gate::The gate received no job results.' + exit 1 + } + $mismatches = [Collections.Generic.List[string]]::new() + $rows = [Collections.Generic.List[string]]::new() + foreach ($job in @($needs.Keys | Sort-Object)) { + $result = [string] $needs[$job].result + $required = 'success' + $reason = 'every job must succeed' + if ($job -eq 'sonar') { + if ($sonarExpected) { + $reason = "SONAR_EXPECTED=true on ${env:EVENT}: the analysis must run and pass" + if ($result -eq 'skipped') { + $upstream = [string] $needs['build-test'].result + $reason += ($upstream -ne 'success') ? "; skipped because build-test was $upstream" : '; skipped although expected: the sonar condition drifted' + } + } + else { + $required = 'skipped' + $reason = "SONAR_EXPECTED=false on ${env:EVENT} (fork, Dependabot, merge_group or release run)" + if ($result -ne 'skipped') { + $reason += '; it ran although not expected: the sonar condition drifted' + } + } + } + if ($result -ne $required) { + $mismatches.Add("$job is $result, required $required") + } + $rows.Add("| $job | $result | $required | $reason |") + } + @('### Gate', '', '| Job | Result | Required | Reason |', '| --- | --- | --- | --- |') + $rows | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($mismatches.Count -gt 0) { + Write-Host "::error title=Gate::$($mismatches -join '; ')." exit 1 } + Write-Host "All $($needs.Count) jobs have their required result." diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 0997cb8f..2027ff23 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -1,13 +1,13 @@ name: Main CI +# Every commit that lands on main, and manual runs on any branch. There is no merge queue, so there is no merge_group +# trigger: an event path that never runs must not live in the required check. on: push: branches: [ main ] - merge_group: - types: [ checks_requested ] workflow_dispatch: -# No concurrency group: every main commit and merge group keeps its own complete run. +# No concurrency group: every main commit keeps its own complete run. permissions: contents: read @@ -21,6 +21,6 @@ jobs: name: CI uses: ./.github/workflows/ci.yml with: - sonar: true # ci.yml skips it for the merge queue and only reports the quality gate outside pull requests + sonar: true # a push to main reports the quality gate; a manual run waits for it secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 18a6b3a6..16862436 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -1,5 +1,6 @@ name: Pull request CI +# No path filter: the required check "CI / Gate" must report on every pull request. on: pull_request: types: [ opened, synchronize, reopened, ready_for_review ] @@ -23,6 +24,6 @@ jobs: if: ${{ !github.event.pull_request.draft }} uses: ./.github/workflows/ci.yml with: - sonar: true # ci.yml still skips Sonar for fork and Dependabot pull requests, which receive no secrets + sonar: true # the gate still expects no Sonar run for fork and Dependabot pull requests, which receive no secrets secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 04df00f1..acd0bad7 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,7 +1,9 @@ name: Sonar -# Reusable SonarQube Cloud analysis, called by ci.yml after build-test. It reuses the coverage and nuget-package -# artifacts of the same run; only the instrumented build is repeated, because the scanner must observe a compilation. +# Reusable SonarQube Cloud analysis, called by ci.yml after build-test when SONAR_EXPECTED is true; the gate then +# requires it to succeed. It reuses the coverage and nuget-package artifacts of the same run; only the instrumented build +# is repeated, because the scanner must observe a compilation. No binary log is kept here: it would record the scanner +# environment. New Code of non-main branches is measured against the reference branch main (a SonarQube Cloud setting). on: workflow_call: @@ -15,7 +17,7 @@ on: type: string default: cheatenginenet wait-quality-gate: - description: Fail the job when the quality gate fails. Pull requests wait for it; main only reports it. + description: Fail the job when the quality gate fails. Every event but push waits for it; main pushes only report it. type: boolean default: true secrets: @@ -135,8 +137,8 @@ jobs: Write-Host '::error::The coverage artifact holds no report.' exit 1 } - # The new-code period follows sonar.projectVersion. The core version of the package built in this run (1.0.1 - # for 1.0.1-alpha.0.37) changes only at a release, so new code on main means changes since the last release. + # sonar.projectVersion is the core version of the package built in this run (2.0.0 for 2.0.0-alpha.0.37), + # which changes only at a release. $packages = @(Get-ChildItem -Path "$env:RUNNER_TEMP/package" -Filter 'CheatEngine.SDK.*.nupkg' -File) if ($packages.Count -ne 1 -or $packages[0].Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+)') { Write-Host '::error::The nuget-package artifact must hold exactly one CheatEngine.SDK package.' @@ -179,10 +181,12 @@ jobs: "/k:$env:SONAR_PROJECT_KEY" "/o:$env:SONAR_ORGANIZATION" "/v:$version" - '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.SDK.Benchmarks/**,tests/CheatEngine.SDK.LivePlugin/**' + # Not analysed: build output, harnesses that are not product or test code, catalogue tooling and docs. + # Tests stay analysed (the tests/** issue ignores above exist for them) but never count for coverage. + '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.SDK.Benchmarks/**,tests/CheatEngine.SDK.LivePlugin/**,tests/CheatEngine.SDK.QualificationTarget/**,tests/native-host-emulator/**,eng/tools/**,docs/**' # CI publishes managed coverage only. Keep build-time tooling and test-only sources out of the product # coverage metric instead of presenting an incomplete report as if it covered those paths. - '/d:sonar.coverage.exclusions=tests/**,eng/**' + '/d:sonar.coverage.exclusions=tests/**,eng/**,docs/**' "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml" "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs new file mode 100644 index 00000000..05a81f7c --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs @@ -0,0 +1,297 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// The required check "CI / Gate" (contract 1.2 and 1.7): the callers, the gate job and its inputs, the job table of +/// ci.yml, the Sonar expectation and the triggers. +/// +public sealed partial class WorkflowContractTests +{ + private const string PipelineUses = "./.github/workflows/ci.yml"; + + [Fact] + public void Callers_invoke_ci_through_job_ci_named_CI() + { + foreach (string caller in WorkflowContract.Callers) + { + WorkflowJob job = WorkflowFile.LoadWorkflow(caller).Job(WorkflowContract.CallerJobId); + Assert.True(string.Equals(WorkflowContract.CallerJobName, job.Name, StringComparison.Ordinal), + $"{job.Location} must be named '{WorkflowContract.CallerJobName}': the required check is 'CI / Gate'."); + Assert.Equal(PipelineUses, job.Uses); + } + + // No other job calls the pipeline: a second caller would publish the gate under another check name. + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + foreach (WorkflowJob job in workflow.Jobs()) + { + if (string.Equals(job.Uses, PipelineUses, StringComparison.Ordinal)) + { + Assert.True(string.Equals(job.Id, WorkflowContract.CallerJobId, StringComparison.Ordinal) && + string.Equals(job.Name, WorkflowContract.CallerJobName, StringComparison.Ordinal), + $"{job.Location} calls ci.yml; only a job 'ci' named 'CI' may."); + } + } + } + } + + [Fact] + public void Pull_request_and_main_callers_request_sonar_and_the_release_run_never_does() + { + foreach (string caller in new[] { "pull-request-ci.yml", "main-ci.yml" }) + { + WorkflowJob job = WorkflowFile.LoadWorkflow(caller).Job(WorkflowContract.CallerJobId); + Assert.Equal("true", WorkflowJob.With(job.Node, "sonar")); + YamlMappingNode secrets = Assert.IsType(WorkflowFile.Mapping(job.Node, "secrets")); + Assert.Equal("${{ secrets.SONAR_TOKEN }}", WorkflowFile.Scalar(secrets, "SONAR_TOKEN")); + } + + // The tag run builds the release candidate; SONAR_EXPECTED is false there (contract 1.11). + WorkflowJob release = WorkflowFile.LoadWorkflow("release.yml").Job(WorkflowContract.CallerJobId); + Assert.Null(WorkflowJob.With(release.Node, "sonar")); + } + + [Fact] + public void Gate_job_is_named_Gate_runs_always_and_has_no_permissions() + { + WorkflowJob gate = Pipeline().Job(WorkflowContract.GateJobId); + + Assert.Equal(WorkflowContract.GateJobName, gate.Name); + Assert.Equal("always()", gate.Condition); + Assert.True(gate.Node.Children.TryGetValue(new YamlScalarNode("permissions"), out YamlNode? permissions), + "The gate must declare 'permissions: {}'."); + Assert.Empty(Assert.IsType(permissions).Children); + Assert.Empty(gate.StepsUsing("actions/checkout@")); + + YamlMappingNode step = Assert.Single(gate.Steps); + Assert.Equal("${{ toJSON(needs) }}", WorkflowJob.Env(step, "NEEDS")); + Assert.Equal("${{ github.event_name }}", WorkflowJob.Env(step, "EVENT")); + string run = WorkflowFile.Scalar(step, "run") ?? ""; + // Data-driven: the verdict comes from a required result per job, never from chained -and/-or conditions. + Assert.DoesNotContain(" -and ", run, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(" -or ", run, StringComparison.OrdinalIgnoreCase); + Assert.Contains("$required = 'success'", run, StringComparison.Ordinal); + Assert.Contains("$required = 'skipped'", run, StringComparison.Ordinal); + Assert.Contains("if ($result -ne $required)", run, StringComparison.Ordinal); + Assert.Contains("| Job | Result | Required | Reason |", run, StringComparison.Ordinal); + Assert.Contains("Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8", run, StringComparison.Ordinal); + Assert.Contains("exit 1", run, StringComparison.Ordinal); + } + + [Fact] + public void Gate_needs_every_other_ci_job_except_the_advisory_allowlist() + { + WorkflowFile pipeline = Pipeline(); + HashSet needs = new(pipeline.Job(WorkflowContract.GateJobId).Needs(), StringComparer.Ordinal); + HashSet expected = new(StringComparer.Ordinal); + foreach (WorkflowJob job in pipeline.Jobs()) + { + if (string.Equals(job.Id, WorkflowContract.GateJobId, StringComparison.Ordinal)) + { + continue; + } + + if (WorkflowContract.AdvisoryJobs.Contains(job.Id)) + { + Assert.Equal("true", WorkflowFile.Scalar(job.Node, "continue-on-error")); + Assert.False(needs.Contains(job.Id), $"The advisory job '{job.Id}' must stay out of gate.needs."); + continue; + } + + expected.Add(job.Id); + } + + List missing = [.. expected.Except(needs, StringComparer.Ordinal)]; + List extra = [.. needs.Except(expected, StringComparer.Ordinal)]; + Assert.True(missing.Count == 0 && extra.Count == 0, + $"gate.needs must list every other ci.yml job. Missing: {string.Join(", ", missing)}. Unknown: {string.Join(", ", extra)}."); + } + + [Fact] + public void Ci_jobs_match_the_frozen_contract_ids_and_names() + { + Dictionary jobs = new(StringComparer.Ordinal); + foreach (WorkflowJob job in Pipeline().Jobs()) + { + jobs.Add(job.Id, job); + } + + foreach (PipelineJob expected in WorkflowContract.Jobs) + { + Assert.True(jobs.Remove(expected.Id, out WorkflowJob? job), $"ci.yml has no job '{expected.Id}'."); + Assert.Equal(expected.Name, job.Name); + Assert.Equal(expected.RunsOn, job.RunsOn); + Assert.Equal(expected.TimeoutMinutes?.ToString(System.Globalization.CultureInfo.InvariantCulture), + WorkflowFile.Scalar(job.Node, "timeout-minutes")); + } + + // Anything else must be a job id reserved for a later wave, with its reserved name. + foreach (WorkflowJob job in jobs.Values) + { + Assert.True(WorkflowContract.ReservedJobs.TryGetValue(job.Id, out string? name), + $"ci.yml job '{job.Id}' is not in the contract (shared contract 1.6)."); + Assert.Equal(name, job.Name); + } + } + + [Fact] + public void Ci_declares_exactly_the_contract_inputs_and_secret() + { + YamlMappingNode call = Assert.IsType(Pipeline().Trigger("workflow_call")); + Assert.Equal(["workflow_call"], Pipeline().Triggers()); + + YamlMappingNode inputs = Assert.IsType(WorkflowFile.Mapping(call, "inputs")); + Assert.Equal(["sonar", "package-version", "package-retention-days"], WorkflowFile.Keys(inputs)); + AssertInput(inputs, "sonar", "boolean", "false"); + AssertInput(inputs, "package-version", "string", ""); + AssertInput(inputs, "package-retention-days", "number", "7"); + + YamlMappingNode secrets = Assert.IsType(WorkflowFile.Mapping(call, "secrets")); + Assert.Equal(["SONAR_TOKEN"], WorkflowFile.Keys(secrets)); + Assert.Equal("false", WorkflowFile.Scalar(Assert.IsType(WorkflowFile.Mapping(secrets, "SONAR_TOKEN")), "required")); + Assert.False(WorkflowFile.Has(call, "outputs"), "ci.yml has no workflow_call outputs in v1 (contract 1.4)."); + + // No repository variable steers the pipeline (contract 1.4). + Assert.DoesNotContain("vars.", Pipeline().Text, StringComparison.Ordinal); + } + + [Fact] + public void Sonar_condition_equals_the_gate_sonar_expected_expression() + { + WorkflowFile pipeline = Pipeline(); + string condition = WorkflowFile.NormalizeWhitespace(pipeline.Job("sonar").Condition ?? ""); + YamlMappingNode gateStep = Assert.Single(pipeline.Job(WorkflowContract.GateJobId).Steps); + string expected = WorkflowFile.NormalizeWhitespace(WorkflowJob.Env(gateStep, "SONAR_EXPECTED") ?? ""); + + Assert.Equal(WorkflowContract.SonarExpected, condition); + Assert.Equal(condition, expected); + } + + [Fact] + public void Sonar_waits_for_the_quality_gate_outside_push_events() + { + WorkflowJob sonar = Pipeline().Job("sonar"); + Assert.Equal("./.github/workflows/sonar.yml", sonar.Uses); + Assert.Equal(["build-test"], sonar.Needs()); + Assert.Equal("${{ github.event_name != 'push' }}", WorkflowJob.With(sonar.Node, "wait-quality-gate")); + Assert.Equal("${{ secrets.SONAR_TOKEN }}", + WorkflowFile.Scalar(Assert.IsType(WorkflowFile.Mapping(sonar.Node, "secrets")), "SONAR_TOKEN")); + + WorkflowJob analyze = WorkflowFile.LoadWorkflow(WorkflowContract.Sonar).Job("analyze"); + YamlMappingNode env = Assert.IsType(WorkflowFile.Mapping(analyze.Node, "env")); + Assert.Equal("${{ inputs.wait-quality-gate }}", WorkflowFile.Scalar(env, "SONAR_WAIT_QUALITY_GATE")); + Assert.Contains("/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE", + WorkflowFile.Scalar(analyze.Step("Begin analysis"), "run"), StringComparison.Ordinal); + } + + [Fact] + public void Sonar_excludes_non_product_trees_from_analysis_and_coverage() + { + string begin = WorkflowFile.Scalar( + WorkflowFile.LoadWorkflow(WorkflowContract.Sonar).Job("analyze").Step("Begin analysis"), "run") ?? ""; + + HashSet exclusions = new(SonarProperty(begin, "sonar.exclusions").Split(','), StringComparer.Ordinal); + foreach (string tree in new[] + { + "artifacts/**", "tests/CheatEngine.SDK.QualificationTarget/**", "tests/native-host-emulator/**", "eng/tools/**", + "docs/**" + }) + { + Assert.True(exclusions.Contains(tree), $"sonar.exclusions must list {tree}."); + } + + // Tests stay analysed (their issues are triaged by rule), but they never count as product coverage. + Assert.False(exclusions.Contains("tests/**"), "Tests stay analysed; only their coverage is excluded."); + HashSet coverage = new(SonarProperty(begin, "sonar.coverage.exclusions").Split(','), StringComparer.Ordinal); + foreach (string tree in new[] { "tests/**", "eng/**", "docs/**" }) + { + Assert.True(coverage.Contains(tree), $"sonar.coverage.exclusions must list {tree}."); + } + } + + [Fact] + public void No_workflow_uses_pull_request_target_or_a_merge_group_trigger() + { + foreach (WorkflowFile workflow in WorkflowFile.LoadWorkflows()) + { + IReadOnlyList triggers = workflow.Triggers(); + Assert.False(triggers.Contains("pull_request_target", StringComparer.Ordinal), + $"{workflow.FileName} must not use pull_request_target: it runs pull-request code with the base repository's secrets."); + Assert.False(triggers.Contains("merge_group", StringComparer.Ordinal), + $"{workflow.FileName} must not listen to merge_group: there is no merge queue, and an untested event path must not feed the required check."); + } + } + + [Fact] + public void Pull_request_and_policy_workflows_have_no_path_filters() + { + foreach (string fileName in new[] { "pull-request-ci.yml", "pr-policy.yml" }) + { + WorkflowFile? workflow = WorkflowFile.TryLoadWorkflow(fileName); + if (workflow is null) + { + // pr-policy.yml arrives with the governance work; pull-request-ci.yml must always exist. + Assert.False(string.Equals(fileName, "pull-request-ci.yml", StringComparison.Ordinal), $"{fileName} is missing."); + continue; + } + + foreach (string trigger in workflow.Triggers()) + { + YamlMappingNode? configuration = workflow.Trigger(trigger); + if (configuration is null) + { + continue; + } + + Assert.False(WorkflowFile.Has(configuration, "paths") || WorkflowFile.Has(configuration, "paths-ignore"), + $"{fileName} '{trigger}' must not filter paths: its required check must report on every pull request."); + } + } + } + + [Fact] + public void Main_ci_runs_every_push_to_main_without_a_concurrency_group() + { + WorkflowFile main = WorkflowFile.LoadWorkflow("main-ci.yml"); + + Assert.Equal(["push", "workflow_dispatch"], main.Triggers()); + YamlMappingNode push = Assert.IsType(main.Trigger("push")); + Assert.Equal(["main"], WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(push, "branches")))); + Assert.False(WorkflowFile.Has(main.Root, "concurrency"), + "main-ci.yml keeps every main commit's run: a concurrency group would cancel or drop intermediate runs."); + } + + [Fact] + public void Pull_request_ci_skips_drafts_and_cancels_superseded_runs() + { + WorkflowFile pullRequest = WorkflowFile.LoadWorkflow("pull-request-ci.yml"); + + Assert.Equal(["pull_request"], pullRequest.Triggers()); + YamlMappingNode trigger = Assert.IsType(pullRequest.Trigger("pull_request")); + Assert.Equal(["opened", "synchronize", "reopened", "ready_for_review"], + WorkflowFile.ScalarValues(Assert.IsType(WorkflowFile.Sequence(trigger, "types")))); + Assert.Equal("${{ !github.event.pull_request.draft }}", pullRequest.Job(WorkflowContract.CallerJobId).Condition); + + YamlMappingNode concurrency = Assert.IsType(WorkflowFile.Mapping(pullRequest.Root, "concurrency")); + Assert.Equal("${{ github.workflow }}-${{ github.event.pull_request.number }}", WorkflowFile.Scalar(concurrency, "group")); + Assert.Equal("true", WorkflowFile.Scalar(concurrency, "cancel-in-progress")); + } + + private static void AssertInput(YamlMappingNode inputs, string name, string type, string defaultValue) + { + YamlMappingNode input = Assert.IsType(WorkflowFile.Mapping(inputs, name)); + Assert.Equal(type, WorkflowFile.Scalar(input, "type")); + Assert.Equal(defaultValue, WorkflowFile.Scalar(input, "default")); + } + + private static string SonarProperty(string beginScript, string property) + { + string marker = $"/d:{property}="; + int start = beginScript.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0, $"The Begin analysis step does not set {property}."); + start += marker.Length; + int end = beginScript.IndexOf('\'', start); + return beginScript[start..end]; + } +} From c6cc9fe2a480dc8b46c4a49ca8c865dbf160fa62 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:53:55 +0200 Subject: [PATCH 057/199] Add the advisory client canary script An SDK change that breaks CheatEngine.Client was discovered only after publication (audit Q48, A11-30, ADR-10). The client stays on CheatEngine.SDK 1.0.0, so the check must inform without gating. eng/ci/Invoke-ClientCanary.ps1 takes the nupkg of the run and a Client checkout (or clones CheatEngineNet/CheatEngine.Client at a ref), writes a temporary NuGet.Config whose packageSourceMapping sends CheatEngine.SDK to a folder holding only that nupkg and every other id to nuget.org, points NUGET_PACKAGES to an isolated folder, then restores and builds the Client solution with the Client's canary switch (CheatEngineSdkVersion set to the package version, CheatEngineSdkUpperBound=3.0.0, CheatEngineSdkCanary=true, CheatEngineClientAllowUnsupportedSdk=true). The committed Client lock files are re-evaluated in the throw-away checkout (--force-evaluate with RestoreLockedMode=false), because RestorePackagesWithLockFile=false next to an existing lock fails with NU1005. Every distinct error line becomes an entry of client-canary-report.json (eng/ci/client-canary-report.v0.schema.json) and of a Markdown summary. The script exits 0 whatever the Client does and fails only when the experiment cannot run. It is not wired into ci.yml: the client-canary job (advisory, continue-on-error, outside gate.needs) is wired at the end of wave 2. Dry runs against a local CheatEngine.SDK 2.0.0-alpha.0.62 nupkg: Client main reports RestoreFailed (NU1103: main has no SDK version override yet); a Client branch with the override reports BuildFailed with a real API break (CS1729 on AddressResolutionOptions); the global package cache was untouched. NU1005: https://learn.microsoft.com/nuget/reference/errors-and-warnings/nu1005 --- CheatEngine.SDK.slnx | 2 + eng/ci/Invoke-ClientCanary.ps1 | 263 ++++++++++++++++++ eng/ci/client-canary-report.v0.schema.json | 138 +++++++++ .../Workflows/ClientCanaryScriptTests.cs | 101 +++++++ 4 files changed, 504 insertions(+) create mode 100644 eng/ci/Invoke-ClientCanary.ps1 create mode 100644 eng/ci/client-canary-report.v0.schema.json create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Workflows/ClientCanaryScriptTests.cs diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index dc31aaab..43c06477 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -54,6 +54,8 @@ + + diff --git a/eng/ci/Invoke-ClientCanary.ps1 b/eng/ci/Invoke-ClientCanary.ps1 new file mode 100644 index 00000000..dfd45f63 --- /dev/null +++ b/eng/ci/Invoke-ClientCanary.ps1 @@ -0,0 +1,263 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Builds CheatEngine.Client against a CheatEngine.SDK package from this repository and reports what breaks. + +.DESCRIPTION + The advisory client-canary job (audit Q48, ADR-10) answers one question before an SDK release: which Client code + stops compiling against this SDK package? It never gates: CheatEngine.Client stays on CheatEngine.SDK 1.0.0 and + moves to 2.x only through its documented migration (docs/migration/sdk-2.0.md of the Client). + + 1. The Client is taken from -ClientDirectory (an actions/checkout of CheatEngineNet/CheatEngine.Client), or cloned + shallowly from -ClientRepository at -ClientRef. + 2. A temporary NuGet.Config maps CheatEngine.SDK to a local folder feed that holds only -PackagePath, and every + other package to nuget.org (packageSourceMapping). NUGET_PACKAGES points to an isolated folder, so the global + package cache never receives the branch package. + 3. The Client solution is restored (re-evaluating its lock files, never in locked mode) and built in Release + with the Client's canary switch: the SDK version property set to the package version, plus -CanaryProperty + (defaults follow the Client's eng/CheatEngineSdk.props: a 3.0.0 upper bound, CheatEngineSdkCanary, unsupported + SDK allowed). + 4. Every distinct `error : ` line of restore and build becomes an entry of + client-canary-report.json (cheatengine-client-canary-report/v0, eng/ci/client-canary-report.v0.schema.json) + and client-canary-report.md, with paths relative to the Client root. + + The script exits 0 whether or not the Client compiles; it fails only when it cannot run the experiment (missing + package, clone failure, unusable output directory). + +.PARAMETER PackagePath + The CheatEngine.SDK..nupkg to test (the nuget-package artifact of the same run). + +.PARAMETER OutputDirectory + Receives client-canary-report.json and client-canary-report.md. + +.PARAMETER ClientDirectory + An existing checkout of CheatEngine.Client. When empty, the script clones -ClientRepository at -ClientRef. + +.PARAMETER ClientRepository + The Client repository to clone. + +.PARAMETER ClientRef + The branch or tag to clone. + +.PARAMETER SdkVersionProperty + The MSBuild property that selects the consumed CheatEngine.SDK version in the Client. + +.PARAMETER CanaryProperty + Additional Name=Value MSBuild properties of the Client's canary switch. + +.PARAMETER WorkDirectory + Scratch directory for the feed, the configuration, the package folder and the clone. + +.EXAMPLE + ./eng/ci/Invoke-ClientCanary.ps1 -PackagePath artifacts/nuget/CheatEngine.SDK.2.0.0-alpha.0.62.nupkg -OutputDirectory artifacts/client-canary +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PackagePath, + + [Parameter(Mandatory)] + [string] $OutputDirectory, + + [string] $ClientDirectory = '', + + [string] $ClientRepository = 'https://github.com/CheatEngineNet/CheatEngine.Client.git', + + [string] $ClientRef = 'main', + + [string] $SdkVersionProperty = 'CheatEngineSdkVersion', + + # RestorePackagesWithLockFile=false is deliberately absent: NuGet refuses it while lock files exist (NU1005). The + # restore instead re-evaluates the committed lock files (--force-evaluate) in this throw-away checkout. + [string[]] $CanaryProperty = @( + 'CheatEngineSdkUpperBound=3.0.0' + 'CheatEngineSdkCanary=true' + 'RestoreLockedMode=false' + 'CheatEngineClientAllowUnsupportedSdk=true' + ), + + [string] $WorkDirectory = (Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) 'client-canary') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$InformationPreference = 'Continue' + +$reportSchema = 'cheatengine-client-canary-report/v0' +$solutionName = 'CheatEngine.Client.slnx' +$errorLine = '\berror (?[A-Z][A-Za-z]*\d+)\s*:\s*(?.+?)(?:\s+\[[^\]]+\])?\s*$' +# `File.cs(12,5): error CS0246: ...` or `Project.csproj : error NU1102: ...`; the line part is optional. +$locationPrefix = '^\s*(?[^(]+?)\s*(?:\((?\d+)(?:,\d+)?\))?\s*:\s*$' + +function Invoke-Logged { + param( + [Parameter(Mandatory)] [string] $Label, + [Parameter(Mandatory)] [string[]] $Arguments, + [Parameter(Mandatory)] [string] $LogPath + ) + + Write-Information "dotnet $($Arguments -join ' ')" + & dotnet @Arguments 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $LogPath | Out-Host + $exitCode = $LASTEXITCODE + Write-Information "$Label exited with code $exitCode." + return $exitCode +} + +# One entry per distinct (code, file, line, message); MSBuild repeats every error in its final summary. +function Get-Diagnostic { + param( + [Parameter(Mandatory)] [string] $LogPath, + [Parameter(Mandatory)] [string] $Root + ) + + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $diagnostics = [Collections.Generic.List[object]]::new() + $rootPrefix = $Root.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar + foreach ($line in [IO.File]::ReadAllLines($LogPath)) { + if ($line -notmatch $errorLine) { + continue + } + $code = $Matches['code'] + $message = $Matches['message'].Trim() + $prefix = $line.Substring(0, $line.IndexOf(" error $code", [StringComparison]::Ordinal) + 1) + $file = $null + $lineNumber = $null + if ($prefix -match $locationPrefix) { + $file = $Matches['file'].Trim() + $lineNumber = if ($Matches.ContainsKey('line')) { [int] $Matches['line'] } else { $null } + if ($file.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { + $file = $file.Substring($rootPrefix.Length) + } + $file = $file.Replace('\', '/') + if ([IO.Path]::IsPathRooted($file)) { + $file = [IO.Path]::GetFileName($file) + } + } + $message = $message.Replace($rootPrefix, '').Replace($Root, '.') + if ($seen.Add("$code|$file|$lineNumber|$message")) { + $diagnostics.Add([ordered]@{ code = $code; file = $file; line = $lineNumber; message = $message }) + } + } + return , $diagnostics +} + +$package = Get-Item -LiteralPath ([IO.Path]::GetFullPath($PackagePath, $PWD.Path)) +if ($package.Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.nupkg$') { + throw "'$($package.Name)' is not a CheatEngine.SDK..nupkg." +} +$packageVersion = $Matches['version'] +$packageSha256 = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + +$work = [IO.Path]::GetFullPath($WorkDirectory, $PWD.Path) +if (Test-Path -LiteralPath $work) { + Remove-Item -LiteralPath $work -Recurse -Force +} +$feed = Join-Path $work 'feed' +$packages = Join-Path $work 'packages' +$logs = Join-Path $work 'logs' +New-Item -ItemType Directory -Path $feed, $packages, $logs -Force | Out-Null +Copy-Item -LiteralPath $package.FullName -Destination $feed + +if ($ClientDirectory) { + $client = [IO.Path]::GetFullPath($ClientDirectory, $PWD.Path) +} +else { + $client = Join-Path $work 'client' + & git clone --quiet --depth 1 --single-branch --branch $ClientRef $ClientRepository $client + if ($LASTEXITCODE -ne 0) { + throw "Cloning $ClientRepository at '$ClientRef' failed with exit code $LASTEXITCODE." + } +} +$solution = Join-Path $client $solutionName +if (-not (Test-Path -LiteralPath $solution -PathType Leaf)) { + throw "'$client' holds no $solutionName." +} +$clientCommit = "$(& git -C $client rev-parse HEAD)".Trim() +if ($LASTEXITCODE -ne 0 -or $clientCommit -notmatch '^[0-9a-f]{40}$') { + throw "Could not read the commit of the Client checkout '$client'." +} + +# CheatEngine.SDK resolves only from the local feed; everything else only from nuget.org. +$configuration = Join-Path $work 'NuGet.Config' +$feedUri = [Security.SecurityElement]::Escape($feed) +@" + + + + + + + + + + + + + + + + +"@ | Set-Content -LiteralPath $configuration -Encoding utf8NoBOM + +$properties = @("-p:$SdkVersionProperty=$packageVersion") + @($CanaryProperty | ForEach-Object { "-p:$_" }) +$previousPackages = $env:NUGET_PACKAGES +$env:NUGET_PACKAGES = $packages +Push-Location -LiteralPath $client +try { + $restoreLog = Join-Path $logs 'restore.log' + $restoreExit = Invoke-Logged -Label 'Restore' -LogPath $restoreLog -Arguments (@('restore', $solution, '--configfile', $configuration, '--force-evaluate') + $properties) + $buildExit = $null + $buildLog = Join-Path $logs 'build.log' + if ($restoreExit -eq 0) { + $buildExit = Invoke-Logged -Label 'Build' -LogPath $buildLog -Arguments (@('build', $solution, '-c', 'Release', '--no-restore') + $properties) + } +} +finally { + Pop-Location + $env:NUGET_PACKAGES = $previousPackages +} + +$diagnostics = Get-Diagnostic -LogPath $restoreLog -Root $client +if ($null -ne $buildExit) { + $diagnostics.AddRange((Get-Diagnostic -LogPath $buildLog -Root $client)) +} +$outcome = if ($restoreExit -ne 0) { 'RestoreFailed' } elseif ($buildExit -ne 0) { 'BuildFailed' } else { 'Compatible' } + +$report = [ordered]@{ + schema = $reportSchema + sdkPackage = [ordered]@{ id = 'CheatEngine.SDK'; version = $packageVersion; sha256 = $packageSha256 } + client = [ordered]@{ repository = $ClientRepository; ref = $ClientRef; commit = $clientCommit } + properties = @($properties | ForEach-Object { $_.Substring(3) }) + restoreExitCode = $restoreExit + buildExitCode = $buildExit + outcome = $outcome + errorCount = $diagnostics.Count + errors = @($diagnostics) + createdUtc = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) +} + +$output = [IO.Path]::GetFullPath($OutputDirectory, $PWD.Path) +New-Item -ItemType Directory -Path $output -Force | Out-Null +[IO.File]::WriteAllText((Join-Path $output 'client-canary-report.json'), (ConvertTo-Json -InputObject $report -Depth 5) + "`n", [Text.UTF8Encoding]::new($false)) + +$byCode = @($diagnostics | Group-Object -Property { $_.code } | Sort-Object -Property Count -Descending) +$markdown = [Collections.Generic.List[string]]::new() +$markdown.Add('### Client canary (advisory)') +$markdown.Add('') +$markdown.Add("CheatEngine.Client ``$ClientRef`` (``$clientCommit``) against CheatEngine.SDK ``$packageVersion`` (SHA-256 ``$packageSha256``): **$outcome**, $($diagnostics.Count) distinct error(s). This job never gates.") +if ($byCode.Count -gt 0) { + $markdown.Add('') + $markdown.Add('| Code | Count | First occurrence |') + $markdown.Add('| --- | ---: | --- |') + foreach ($group in $byCode) { + $first = $group.Group[0] + $where = if (-not $first.file) { '' } elseif ($null -eq $first.line) { "``$($first.file)`` " } else { "``$($first.file):$($first.line)`` " } + $markdown.Add("| $($group.Name) | $($group.Count) | $where$($first.message.Replace('|', '\|')) |") + } +} +[IO.File]::WriteAllLines((Join-Path $output 'client-canary-report.md'), [string[]] $markdown, [Text.UTF8Encoding]::new($false)) +if ($env:GITHUB_STEP_SUMMARY) { + $markdown | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +} +$markdown | ForEach-Object { Write-Information $_ } +exit 0 diff --git a/eng/ci/client-canary-report.v0.schema.json b/eng/ci/client-canary-report.v0.schema.json new file mode 100644 index 00000000..010df826 --- /dev/null +++ b/eng/ci/client-canary-report.v0.schema.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/eng/ci/client-canary-report.v0.schema.json", + "title": "CheatEngine.Client canary report v0", + "description": "What breaks when CheatEngine.Client is built against a CheatEngine.SDK package of this repository. Written by eng/ci/Invoke-ClientCanary.ps1 for the advisory client-canary job (audit Q48); never a gate.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "sdkPackage", + "client", + "properties", + "restoreExitCode", + "buildExitCode", + "outcome", + "errorCount", + "errors", + "createdUtc" + ], + "properties": { + "schema": { + "const": "cheatengine-client-canary-report/v0" + }, + "sdkPackage": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "sha256" + ], + "properties": { + "id": { + "const": "CheatEngine.SDK" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "client": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "ref", + "commit" + ], + "properties": { + "repository": { + "type": "string", + "pattern": "^https://" + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + } + } + }, + "properties": { + "description": "The MSBuild Name=Value global properties of the canary build.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_.-]*=.*$" + } + }, + "restoreExitCode": { + "type": "integer" + }, + "buildExitCode": { + "description": "Null when the restore failed and no build ran.", + "type": [ + "integer", + "null" + ] + }, + "outcome": { + "enum": [ + "Compatible", + "BuildFailed", + "RestoreFailed" + ] + }, + "errorCount": { + "type": "integer", + "minimum": 0 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "file", + "line", + "message" + ], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Z][A-Za-z]*\\d+$" + }, + "file": { + "description": "Relative to the Client root; null for errors without a location.", + "type": [ + "string", + "null" + ] + }, + "line": { + "type": [ + "integer", + "null" + ] + }, + "message": { + "type": "string" + } + } + } + }, + "createdUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/ClientCanaryScriptTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/ClientCanaryScriptTests.cs new file mode 100644 index 00000000..a471578c --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/ClientCanaryScriptTests.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Workflows; + +/// +/// The advisory client-canary script (audit Q48, ADR-10): its report matches its schema, it never touches the global +/// package cache or the Client's committed lock files, and it never fails the run because the Client breaks. +/// +public sealed partial class ClientCanaryScriptTests +{ + private const string ScriptPath = "eng/ci/Invoke-ClientCanary.ps1"; + private const string SchemaPath = "eng/ci/client-canary-report.v0.schema.json"; + + private static readonly string[] s_reportFields = + [ + "schema", "sdkPackage", "client", "properties", "restoreExitCode", "buildExitCode", "outcome", "errorCount", "errors", + "createdUtc" + ]; + + private static readonly string[] s_errorFields = ["code", "file", "line", "message"]; + + [Fact] + public void Client_canary_report_schema_requires_exactly_the_fields_the_script_writes() + { + using JsonDocument schema = JsonDocument.Parse(ReadRepositoryText(SchemaPath)); + JsonElement root = schema.RootElement; + Assert.Equal($"https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/{SchemaPath}", root.GetProperty("$id").GetString()); + Assert.Equal(s_reportFields, Strings(root.GetProperty("required")), StringComparer.Ordinal); + Assert.False(root.GetProperty("additionalProperties").GetBoolean()); + JsonElement error = root.GetProperty("properties").GetProperty("errors").GetProperty("items"); + Assert.Equal(s_errorFields, Strings(error.GetProperty("required")), StringComparer.Ordinal); + Assert.False(error.GetProperty("additionalProperties").GetBoolean()); + + string script = ReadRepositoryText(ScriptPath); + foreach (string field in s_reportFields) + { + Assert.True(Regex.IsMatch(script, $@"(?m)^\s*{field} = ", RegexOptions.None, TimeSpan.FromSeconds(1)), + $"{ScriptPath} does not write '{field}'."); + } + + Assert.Contains("[ordered]@{ code = $code; file = $file; line = $lineNumber; message = $message }", script, StringComparison.Ordinal); + Assert.Contains("$reportSchema = 'cheatengine-client-canary-report/v0'", script, StringComparison.Ordinal); + } + + [Fact] + public void Client_canary_isolates_its_packages_and_never_gates() + { + string script = StripComments(ReadRepositoryText(ScriptPath)); + + // The branch package reaches only an isolated package folder, through a feed mapped to CheatEngine.SDK alone. + Assert.Contains("$env:NUGET_PACKAGES = $packages", script, StringComparison.Ordinal); + Assert.Contains("", script, StringComparison.Ordinal); + Assert.Contains("", script, StringComparison.Ordinal); + + // Lock files are re-evaluated in the throw-away checkout; RestorePackagesWithLockFile=false would fail with NU1005. + Assert.Contains("'--force-evaluate'", script, StringComparison.Ordinal); + Assert.Contains("'RestoreLockedMode=false'", script, StringComparison.Ordinal); + Assert.DoesNotContain("RestorePackagesWithLockFile=false", script, StringComparison.Ordinal); + + // A broken Client is the report, not a failure: the script ends with exit 0. + Assert.EndsWith("exit 0", script.TrimEnd(), StringComparison.Ordinal); + } + + private static string ReadRepositoryText(string relativePath) + { + string path = Path.Combine(RepositoryRoot.Path, relativePath); + Assert.True(File.Exists(path), $"{relativePath} is missing."); + return File.ReadAllText(path).Replace("\r\n", "\n", StringComparison.Ordinal); + } + + private static List Strings(JsonElement array) + { + List values = []; + foreach (JsonElement item in array.EnumerateArray()) + { + values.Add(item.GetString() ?? ""); + } + + return values; + } + + private static string StripComments(string script) + { + List lines = []; + foreach (string line in CommentBlock().Replace(script, "").Split('\n')) + { + if (!line.TrimStart().StartsWith('#')) + { + lines.Add(line); + } + } + + return string.Join('\n', lines); + } + + [GeneratedRegex(@"<#.*?#>", RegexOptions.Singleline | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex CommentBlock(); +} From 71b382abde1a620240b7905ad1b052349e46c449 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:54:16 +0200 Subject: [PATCH 058/199] Document the CI scripts and the workflow contract tests eng/ci/README.md describes what each script of the pipeline proves, which job runs it, how to run it locally with the same command, how the coverage floors are raised (a reviewed commit that copies the CI suggestion) and why native bridge drift is a notice. The Repository.Tests README gains the Workflows/ folder and one promise bullet per contract area, each naming the tests that keep it. --- CheatEngine.SDK.slnx | 1 + eng/ci/README.md | 58 ++++++++++++++++++ .../README.md | 61 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 eng/ci/README.md diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 43c06477..40eb0b76 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -58,6 +58,7 @@ + diff --git a/eng/ci/README.md b/eng/ci/README.md new file mode 100644 index 00000000..875c51ce --- /dev/null +++ b/eng/ci/README.md @@ -0,0 +1,58 @@ +# CI scripts + +The scripts that the jobs of [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) run. Each one also runs on a +developer machine (PowerShell 7, the exact .NET SDK of `global.json`), so a red CI step can be reproduced locally with +the same command. The workflow contract they implement is frozen by the C# tests in +[`tests/CheatEngine.SDK.Repository.Tests/Workflows`](../../tests/CheatEngine.SDK.Repository.Tests/Workflows) and by +[`NativeBridgePeAuditTests`](../../tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs); a rule that only a +script checked would silently rot, so every script either is the CI step itself or is asserted by those tests. + +| Script | Job, step | What it proves | +|---|---|---| +| `Build-NativeBridge.ps1` | `native`, Build bridge | The Lua protection bridge builds with the pinned xmake, MSVC toolset and Windows SDK; a second build and a build from a copy of the two inputs in another directory have the same SHA-256 (reproducible, path independent); the PE header records the linker of the pinned toolset. Writes the toolchain facts to the step summary and the job outputs. | +| `Test-SdkPackage.ps1` | `build-test` (Release), Pack | The Release leg packed exactly one `CheatEngine.SDK..nupkg` (the exact name when a release version is required), with its nuspec identity, the embedded SPDX SBOM and the CI-built bridge. Exposes the file and its SHA-256 to the packaging tests. | +| `New-BuildInfo.ps1` | `build-test` (Release), Write build info | Writes `build-info.json` ([`build-info.v0.schema.json`](build-info.v0.schema.json)): source, run, .NET SDK, runner image, bridge toolchain and package hashes. | +| `Test-TestModuleInventory.ps1` | `build-test`, Check test module inventory | Every `tests/**/*.Tests.csproj` produced its TRX report, ran at least one test and, in Debug, wrote its coverage report. | +| `Test-CoverageBaseline.ps1` | `build-test` (Debug), Check coverage floors | Merges the per-module coverage with the pinned `dotnet-coverage` and holds every shipping assembly to its floor in [`eng/coverage-baseline.json`](../coverage-baseline.json). | +| `Invoke-ScriptAnalysis.ps1` | `lint`, Run PSScriptAnalyzer | Every tracked `*.ps1` passes a hash-verified PSScriptAnalyzer with [`eng/PSScriptAnalyzerSettings.psd1`](../PSScriptAnalyzerSettings.psd1). | +| `Invoke-ClientCanary.ps1` | `client-canary` (advisory, not in the gate) | Builds CheatEngine.Client against the package of the run and reports every error ([`client-canary-report.v0.schema.json`](client-canary-report.v0.schema.json)). It never fails the run because the Client breaks. | + +## Run them locally + +```powershell +# Native bridge (needs xmake 3.0.9 and the MSVC 14.44 toolset with the Windows SDK 10.0.26100.0). +./eng/ci/Build-NativeBridge.ps1 -VsToolset 14.44 -VsSdkVersion 10.0.26100.0 + +# The Debug leg of build-test. +dotnet test --solution CheatEngine.SDK.slnx -c Debug --no-build --results-directory artifacts/test-results/Debug ` + --fail-skips on --report-trx --hangdump --hangdump-timeout 15m --crashdump ` + --coverage --coverage-output-format xml --filter-not-trait "Category=Packaging" +./eng/ci/Test-TestModuleInventory.ps1 -ResultsDirectory artifacts/test-results/Debug -Configuration Debug -RequireCoverage +./eng/ci/Test-CoverageBaseline.ps1 -ResultsDirectory artifacts/test-results/Debug + +# The Release pack checks. +dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget +./eng/ci/Test-SdkPackage.ps1 -PackageDirectory artifacts/nuget + +# Lint. +./eng/ci/Invoke-ScriptAnalysis.ps1 +``` + +`New-BuildInfo.ps1` reads the GitHub Actions environment (`GITHUB_*`, `ImageOS`, `ImageVersion`) and the +`BUILD_INFO_*` variables its help lists; set them by hand to try it outside CI. Consume a locally packed CheatEngine.SDK +only through an isolated `NUGET_PACKAGES` folder: MinVer gives every worktree at the same height the same version, and +the global package cache must never hold a branch package. + +## Coverage floors + +The Debug leg fails when an assembly's line coverage drops below its floor minus the tolerance of +`eng/coverage-baseline.json`. CI never raises a floor: the step summary and the `coverage-report` artifact carry +`suggested-coverage-baseline.json`, and raising the floors is a reviewed commit that copies it. Line coverage is the only +metric because the merged report keeps no branch data. + +## Native bridge drift + +A CI-built bridge whose bytes differ from the checked-in +`native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll` is reported as a notice, never +as a failure: the checked-in DLL is refreshed deliberately, from the `lua-protection-bridge` artifact of a CI run. A +checked-in DLL built from other sources fails the `native` job (source fingerprint check). diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index 66382513..7e05c943 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -21,6 +21,7 @@ this project only reads committed files. It never builds, packs, restores or sta | `Toolchain/` | `ToolchainPinTests` reads `global.json`, `Directory.Build.props` and `Directory.Solution.targets`: exact SDK, analysis-level pin, NuGet audit policy. | | `LockFiles/` | `LockFileTests` mirror the structural checks of `eng/Update-LockFiles.ps1` over the committed `packages.lock.json` files. | | `PublicApi/` | PublicAPI files, `CompatibilitySuppressions.xml` and the `eng/api/*.txt` lists: file shape, declared breaks, Client-induced breaks, enum contracts. | +| `Workflows/` | `WorkflowContractTests` parse `.github/workflows/*.yml` and the composite actions with YamlDotNet and freeze the CI contract; `CoverageBaselineTests`, `BuildInfoSchemaTests` and `ClientCanaryScriptTests` check the files and scripts of `eng/ci/` that CI runs. | Later work adds one folder per contract (for example `Documentation/`, `Workflows/`, `Qualification/`). @@ -89,6 +90,66 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow `Every_lock_file_is_version_2_because_every_project_uses_central_package_management`, `Version_1_lock_files_hold_no_central_transitive_entries`, `Native_aot_projects_lock_the_win_x64_ilcompiler_packages`, `No_lock_file_resolves_a_cheatengine_package`, `Lock_files_end_without_a_final_newline_as_nuget_writes_them`). +- The required check `CI / Gate` keeps its shape: the three callers call `ci.yml` through job `ci` named `CI`, the gate + job `gate` named `Gate` runs `always()` with no permissions, needs every other job except the advisory allowlist, and + decides from a required result per job; the `ci.yml` jobs, inputs and secret are exactly the contract's + (`Callers_invoke_ci_through_job_ci_named_CI`, `Gate_job_is_named_Gate_runs_always_and_has_no_permissions`, + `Gate_needs_every_other_ci_job_except_the_advisory_allowlist`, `Ci_jobs_match_the_frozen_contract_ids_and_names`, + `Ci_declares_exactly_the_contract_inputs_and_secret`). +- Sonar is required exactly when `SONAR_EXPECTED` says so: the job condition and the gate expression are the same text, + the quality gate is awaited outside push events, and non-product trees are excluded + (`Sonar_condition_equals_the_gate_sonar_expected_expression`, `Sonar_waits_for_the_quality_gate_outside_push_events`, + `Sonar_excludes_non_product_trees_from_analysis_and_coverage`, + `Pull_request_and_main_callers_request_sonar_and_the_release_run_never_does`). +- No workflow listens to `pull_request_target` or `merge_group`, the pull-request and policy workflows filter no path, + main keeps every run and pull requests cancel superseded ones + (`No_workflow_uses_pull_request_target_or_a_merge_group_trigger`, `Pull_request_and_policy_workflows_have_no_path_filters`, + `Main_ci_runs_every_push_to_main_without_a_concurrency_group`, `Pull_request_ci_skips_drafts_and_cancels_superseded_runs`). +- Every job runs on `windows-2025` or `ubuntu-24.04` with a timeout, workflows grant read permissions only at the top + level and the pipeline never elevates, every remote action is pinned to a commit with its version, every checkout drops + its credentials, every native command checks its exit code, no run script interpolates an expression, and the + pipeline scripts run in `pwsh` (`Every_job_has_a_timeout_and_a_pinned_runner_label`, + `Workflows_grant_only_read_permissions_at_the_top_level`, `Pipeline_jobs_never_elevate_permissions`, + `Every_remote_action_is_pinned_to_a_full_sha_with_a_version_comment`, `Every_checkout_disables_credential_persistence`, + `Every_native_command_in_a_workflow_script_checks_its_exit_code`, `No_run_script_interpolates_an_expression`, + `Pipeline_workflows_and_composite_actions_run_scripts_in_pwsh`). +- Every dotnet job installs the pinned SDK through the composite action, every restore is locked, and no job reachable + from a release, Sonar or CodeQL run uses a package cache (`Every_dotnet_job_uses_the_composite_setup_action`, + `Composite_setup_restores_in_locked_mode`, `Every_restore_in_the_pipeline_is_locked`, + `Release_reachable_workflows_never_enable_a_package_cache`, `Sonar_restores_locked_from_nuget_org_before_the_scanner_begins`). +- The Release leg packs before it tests and hands the exact nupkg to the packaging tests; the Debug leg excludes them + by trait, never by skip; every module runs once with hang and crash dumps well inside the job timeout and is checked by + the inventory (`Release_leg_packs_before_testing_and_exports_the_exact_nupkg`, + `Debug_leg_excludes_packaging_tests_by_trait_never_by_skip`, `Test_step_runs_every_module_once_with_the_contract_options`, + `Every_test_module_references_the_extensions_the_test_step_uses`, + `Hang_dump_timeout_is_well_below_the_build_test_job_timeout`, `Test_module_inventory_runs_in_both_legs`, + `Build_test_runs_both_configurations_without_fail_fast`). +- Artifacts use the reserved names and retentions only, binary logs and dumps are uploaded on failure only and never + from Sonar or release runs, jobs that version a package fetch full history, the Native AOT probes are published, and + the live probe is compiled exactly once and never shipped (`Every_uploaded_artifact_name_is_reserved`, + `Binlogs_are_uploaded_only_on_failure_and_never_from_sonar_or_release`, `Jobs_that_pack_or_test_fetch_full_history`, + `Aot_job_publishes_the_native_aot_probes`, `Live_probe_is_compiled_by_the_ci_solution_build`). +- actionlint, zizmor and PSScriptAnalyzer are pinned by version and checksum, every zizmor exception carries its reason, + and the format job verifies whitespace without a restore (`Lint_job_checks_out_the_repository_and_runs_every_linter`, + `Zizmor_and_actionlint_are_pinned_by_version_and_checksum`, `Every_zizmor_exception_carries_a_justification_comment`, + `Script_analysis_uses_a_pinned_hash_verified_psscriptanalyzer`, `Format_job_verifies_whitespace_without_restore`). +- The dependency review never skips and reviews pull requests only, and the lock-file job verifies the committed locks + on Windows (`Dependency_review_job_always_runs_and_reviews_only_pull_requests`, + `Dependency_review_configuration_blocks_advisories_and_unreviewed_licenses`, + `Lock_file_job_runs_the_verification_script_on_windows`). +- No workflow runs the local qualification runner or generates ApiCompat suppressions + (`No_workflow_references_the_local_qualification_runner`, `No_workflow_passes_ApiCompatGenerateSuppressionFile`). +- The coverage floors cover exactly the shipping assemblies, are percentages with an explicit tolerance, use the + pinned merge tool, and CI never writes them (`Coverage_baseline_lists_exactly_the_shipping_assemblies`, + `Coverage_floors_are_percentages_and_the_tolerance_is_explicit`, `Coverage_tool_is_pinned_in_the_local_tool_manifest`, + `Debug_leg_checks_the_coverage_floors_and_never_writes_the_baseline`). +- `build-info.json` has exactly the contract fields, rejects any other, is written in full from the native job's + outputs and uploaded by the Release leg (`Build_info_schema_requires_exactly_the_contract_fields`, + `Build_info_schema_rejects_additional_properties`, `Build_info_writer_emits_every_required_field`, + `Release_leg_writes_and_uploads_build_info_from_the_native_job_outputs`). +- The advisory client canary writes the fields of its report schema, isolates the branch package and never fails the + run because the Client breaks (`Client_canary_report_schema_requires_exactly_the_fields_the_script_writes`, + `Client_canary_isolates_its_packages_and_never_gates`). ## Run the tests From d2df612a21716dcaebdcd09a509b19c5c2963dba Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:58:05 +0200 Subject: [PATCH 059/199] Reserve the artifact names of the advisory workflows The governance work adds advisory workflows that upload their own artifacts: dependency-submission.yml hands its snapshot from the detect job to the submit job (dependency-snapshot, 5 days), and scheduled-health.yml keeps its SDK canary, repeated-test and bridge-drift reports (health-sdk-canary and health-test-repeat, 14 days; health-bridge-drift, 30 days). Every_uploaded_artifact_name_is_reserved rejects any name outside the list, so the names and retentions are registered next to those of contract 1.9 before the workflows land; the contract table itself still has to list them. Checked by running the workflow contract tests over this branch with the governance, qualification and release workflows overlaid: every rule holds except the entries that the release workflow rework and the live probe's move into the solution retire, as designed. --- .../Workflows/WorkflowContract.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs index ecf35971..4a5bef44 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContract.cs @@ -86,9 +86,9 @@ internal static class WorkflowContract }; /// - /// Every artifact name a workflow may upload (contract 1.9 with the orchestrator's attestation-bundles and - /// dependency-snapshot), with its retention in days, or null where the contract leaves it to the producer. - /// {configuration} is Debug or + /// Every artifact name a workflow may upload (contract 1.9, the orchestrator's attestation-bundles, and the + /// names of the advisory governance workflows), with its retention in days, or null where the contract leaves it + /// to the producer. {configuration} is Debug or /// Release; binlogs-* names are checked by pattern with . /// public static readonly Dictionary ReservedArtifacts = new(StringComparer.Ordinal) @@ -106,8 +106,12 @@ internal static class WorkflowContract ["lua-surface-report"] = "30", ["client-canary-report"] = "14", ["attestation-bundles"] = null, - // The advisory dependency-submission.yml hands its snapshot from the detect job to the submit job. - ["dependency-snapshot"] = "5" + // Advisory workflows outside the gate: dependency-submission.yml hands its snapshot from the detect job to the + // submit job, and scheduled-health.yml keeps its canary, repeated-test and bridge-drift reports. + ["dependency-snapshot"] = "5", + ["health-sdk-canary"] = "14", + ["health-test-repeat"] = "14", + ["health-bridge-drift"] = "30" }; /// From 6b7fc57a84040461462372fc71454d05c4ef1eb7 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:59:23 +0200 Subject: [PATCH 060/199] Link the Sonar analysis from the job summary The step summaries of the pipeline covered the gate reasons, the package, build info, per-module test counts and coverage, but reaching the Sonar analysis of a run still meant searching SonarQube Cloud (audit register PR-CQ-40). sonar.yml ends with a step that always runs unless the run was cancelled, so it also follows a failed quality gate, and appends the SonarQube Cloud new-code link of the pull request, or of the branch for other events, to the job summary. The pull request number and the branch name reach the script through env, never through an inline expression. The contract test asserts the step's place and condition. --- .github/workflows/sonar.yml | 12 ++++++++++++ .../Workflows/WorkflowContractTests.Gate.cs | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index acd0bad7..8f43828f 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -217,3 +217,15 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "SonarScanner end failed with exit code $LASTEXITCODE." } + + # Also after a failed quality gate: the summary links the analysis of this pull request or branch. + - name: Link the analysis + if: ${{ !cancelled() }} + env: + PULL_REQUEST: ${{ github.event.pull_request.number }} + BRANCH: ${{ github.ref_name }} + run: | + $scope = if ($env:PULL_REQUEST) { "pullRequest=$env:PULL_REQUEST" } else { "branch=$([Uri]::EscapeDataString($env:BRANCH))" } + $link = "https://sonarcloud.io/summary/new_code?id=$([Uri]::EscapeDataString($env:SONAR_PROJECT_KEY))&$scope" + @('### Sonar', '', "[SonarQube Cloud analysis of $env:SONAR_PROJECT_KEY]($link)") | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs index 05a81f7c..810b8709 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Workflows/WorkflowContractTests.Gate.cs @@ -183,6 +183,12 @@ public void Sonar_waits_for_the_quality_gate_outside_push_events() Assert.Equal("${{ inputs.wait-quality-gate }}", WorkflowFile.Scalar(env, "SONAR_WAIT_QUALITY_GATE")); Assert.Contains("/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE", WorkflowFile.Scalar(analyze.Step("Begin analysis"), "run"), StringComparison.Ordinal); + + // A failed quality gate stays one click away: the summary links the analysis even when End analysis fails. + YamlMappingNode link = analyze.Step("Link the analysis"); + Assert.True(analyze.StepIndex("End analysis") < analyze.StepIndex("Link the analysis")); + Assert.Equal("${{ !cancelled() }}", WorkflowFile.Scalar(link, "if")); + Assert.Contains("Out-File -FilePath $env:GITHUB_STEP_SUMMARY", WorkflowFile.Scalar(link, "run"), StringComparison.Ordinal); } [Fact] From 4d6d742513799fac3253509f7f4afd48056f304f Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 06:23:21 +0200 Subject: [PATCH 061/199] Regenerate lock files after S-CI-PIPE S-CI-PIPE adds Microsoft.Testing.Extensions.HangDump and CrashDump 2.4.1 to every *.Tests project through eng/Tests.props (CI passes --hangdump and --crashdump to every module) and YamlDotNet 18.1.0 to CheatEngine.SDK.Repository.Tests for the workflow contract tests. The versions were already pinned in Directory.Packages.props; only the 13 test lock files change. Regenerated with ./eng/Update-LockFiles.ps1 (solution-level --force-evaluate, then the --locked-mode --force verification restores and the structural checks), per shared-contracts section 5.3. --- .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 39 +++++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../packages.lock.json | 33 ++++++++++++++++ .../CheatEngine.SDK.Tests/packages.lock.json | 33 ++++++++++++++++ 13 files changed, 435 insertions(+) diff --git a/tests/CheatEngine.SDK.Abi.Tests/packages.lock.json b/tests/CheatEngine.SDK.Abi.Tests/packages.lock.json index c9855721..6ad5cd97 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.Abi.Tests/packages.lock.json @@ -19,6 +19,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -28,6 +38,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -65,6 +85,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -75,6 +103,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/packages.lock.json b/tests/CheatEngine.SDK.Analyzers.Tests/packages.lock.json index 7f29fa3c..efe5baf3 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.Analyzers.Tests/packages.lock.json @@ -63,6 +63,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -72,6 +82,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -161,6 +181,14 @@ "System.Composition": "10.0.1" } }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -171,6 +199,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.Engine.Tests/packages.lock.json b/tests/CheatEngine.SDK.Engine.Tests/packages.lock.json index a539e5b3..a76ca9f5 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.Engine.Tests/packages.lock.json @@ -19,6 +19,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -28,6 +38,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -65,6 +85,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -75,6 +103,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.Hosting.Tests/packages.lock.json b/tests/CheatEngine.SDK.Hosting.Tests/packages.lock.json index adea1af7..1555397e 100644 --- a/tests/CheatEngine.SDK.Hosting.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.Hosting.Tests/packages.lock.json @@ -19,6 +19,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -28,6 +38,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -65,6 +85,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -75,6 +103,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.LiveProbe.Tests/packages.lock.json b/tests/CheatEngine.SDK.LiveProbe.Tests/packages.lock.json index caad8ebc..e03dc28c 100644 --- a/tests/CheatEngine.SDK.LiveProbe.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.LiveProbe.Tests/packages.lock.json @@ -19,6 +19,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -28,6 +38,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -65,6 +85,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -75,6 +103,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/packages.lock.json b/tests/CheatEngine.SDK.Lua.Interop.Tests/packages.lock.json index 90fe7e3c..4975c0a9 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/packages.lock.json @@ -19,6 +19,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -28,6 +38,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -65,6 +85,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -75,6 +103,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.Lua.Tests/packages.lock.json b/tests/CheatEngine.SDK.Lua.Tests/packages.lock.json index b4c4d2d2..3f4e87af 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.Lua.Tests/packages.lock.json @@ -19,6 +19,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -28,6 +38,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -65,6 +85,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -75,6 +103,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.Repository.Tests/packages.lock.json b/tests/CheatEngine.SDK.Repository.Tests/packages.lock.json index 5bdc204f..e672d60d 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.Repository.Tests/packages.lock.json @@ -19,6 +19,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -28,6 +38,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -55,6 +75,12 @@ "xunit.v3.core.mtp-v2": "[4.0.1]" } }, + "YamlDotNet": { + "type": "Direct", + "requested": "[18.1.0, )", + "resolved": "18.1.0", + "contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw==" + }, "Microsoft.ApplicationInsights": { "type": "Transitive", "resolved": "2.23.0", @@ -65,6 +91,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -75,6 +109,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/packages.lock.json b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/packages.lock.json index b821e74e..4f2582ea 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.SourceGenerators.EngineApi.Tests/packages.lock.json @@ -53,6 +53,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -62,6 +72,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -152,6 +172,14 @@ "System.Composition": "10.0.1" } }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -162,6 +190,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/packages.lock.json b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/packages.lock.json index 326a3b30..bd0bbd44 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.SourceGenerators.EntryPoint.Tests/packages.lock.json @@ -53,6 +53,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -62,6 +72,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -152,6 +172,14 @@ "System.Composition": "10.0.1" } }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -162,6 +190,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/packages.lock.json b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/packages.lock.json index 1142639e..b102d229 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/packages.lock.json @@ -53,6 +53,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -62,6 +72,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -152,6 +172,14 @@ "System.Composition": "10.0.1" } }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -162,6 +190,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/packages.lock.json b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/packages.lock.json index 264a7636..23437b31 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/packages.lock.json @@ -29,6 +29,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -38,6 +48,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -83,6 +103,14 @@ "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" } }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -93,6 +121,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", diff --git a/tests/CheatEngine.SDK.Tests/packages.lock.json b/tests/CheatEngine.SDK.Tests/packages.lock.json index 5bdc204f..c3186255 100644 --- a/tests/CheatEngine.SDK.Tests/packages.lock.json +++ b/tests/CheatEngine.SDK.Tests/packages.lock.json @@ -19,6 +19,16 @@ "Microsoft.Testing.Platform": "2.4.0" } }, + "Microsoft.Testing.Extensions.CrashDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "HwfdRV4Qk8xRcWo8b/m1MG4j+J7AAmqu3Xn+xZc3rVACDSJge9OfBp+f3O/zW8nkKtDves+7SG9a/DY4Ml00xA==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.GitHubActionsReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -28,6 +38,16 @@ "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" } }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -65,6 +85,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, "Microsoft.DiaSymReader": { "type": "Transitive", "resolved": "2.2.10", @@ -75,6 +103,11 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "K14wYgwOfKVELrUh5eBqlC8Wvo9vvhS3ZhIvcswV2uS/ubkTRPSQsN557EZiYUSSoZNxizG+alN4wjtdyLdcyw==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "2.4.0", From c59713fe70cc89f6a9279199228576897f201a03 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 06:25:24 +0200 Subject: [PATCH 062/199] Fix the live probe compile path after integrating S-CI-PIPE S-QUAL (integrated before S-CI-PIPE) added tests/CheatEngine.SDK.LiveProbe to CheatEngine.SDK.slnx, so the solution build of build-test already compiles the probe in both legs. The lot kept its documented fallback (an explicit Release "Compile live probe" step and the probe's line in the composite restore list) because S-QUAL had not landed at its base, and designed WorkflowContractTests.Live_probe_is_compiled_by_the_ci_solution_build to demand removal once the probe joined the solution: that test failed on the integrated head. Remove the step and the restore line; build-test restores only CheatEngine.SDK.slnx. The probe is still compiled once per leg (C0 only, PR-CQ-60) and nothing uploads its output. --- .github/workflows/ci.yml | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0f11c9f..e3a4578b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,9 +157,7 @@ jobs: - name: Setup .NET uses: ./.github/actions/setup-dotnet with: - restore: | - CheatEngine.SDK.slnx - tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj + restore: CheatEngine.SDK.slnx - name: Use CI-built native bridge uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -182,17 +180,6 @@ jobs: throw "$env:CONFIGURATION solution build failed with exit code $LASTEXITCODE." } - # C0 evidence only: the CE 7.7 live probe must keep compiling, but CI never loads or runs it and uploads nothing it - # produces. This step exists until the probe joins CheatEngine.SDK.slnx; WorkflowContractTests then requires its - # removal, because the solution build compiles the probe in both legs. - - name: Compile live probe - if: matrix.configuration == 'Release' - run: | - dotnet build tests/CheatEngine.SDK.LiveProbe/CheatEngine.SDK.LiveProbe.csproj -c Release --no-restore -bl:artifacts/logs/build-test-live-probe.binlog - if ($LASTEXITCODE -ne 0) { - throw "The live probe no longer compiles (exit code $LASTEXITCODE)." - } - # Pack before testing: the packaging tests below consume this exact file, which is then uploaded unchanged. The # pack is incremental after the build and recompiles nothing. - name: Pack From 41ef299993f226115d89b506af0e2e0728f25316 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 03:16:05 +0200 Subject: [PATCH 063/199] Harden Dependabot with cooldowns and SDK-implicit ignores zizmor reported dependabot-cooldown on both ecosystems, and Dependabot could propose packages that must only move together with another pin. - Every ecosystem waits at least 7 days (30 for majors) before proposing a release; GitHub Actions supports default-days only. A cooldown never delays a security update. - Add the dotnet-sdk ecosystem, ignoring new majors (a .NET major is a migration: TargetFramework, AnalysisLevel pin, lock files). - Ignore Microsoft.CodeAnalysis.Analyzers next to the two Roslyn pins (same 5.9.0 floor, CESDK9002), and the SDK-implicit ILLink/ILCompiler packages recorded in the lock files, which move with global.json. - Group security updates, test infrastructure and analyzers before the existing minor-and-patch catch-all (a dependency joins the first group it matches); label the pull requests. - Keep Dependabot's generated "Bump ..." titles: a commit-message prefix would produce "deps: ..." titles that the PR title rule rejects. The new Governance/ folder of the repository tests reads YAML with the YamlDotNet representation model (the key "on" stays a string) and DependabotConfigurationTests pins these rules, including that every Microsoft.CodeAnalysis package pinned to RoslynComponentFloor is ignored. The YamlDotNet reference changes the project's lock file, which is left to the lock-file regeneration of the integration. Options: https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference --- .github/dependabot.yml | 59 +++- .../CheatEngine.SDK.Repository.Tests.csproj | 5 +- .../DependabotConfigurationTests.cs | 193 ++++++++++++ .../Governance/RepositoryFile.cs | 59 ++++ .../Governance/YamlDocument.cs | 278 ++++++++++++++++++ .../README.md | 8 + 6 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/DependabotConfigurationTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/RepositoryFile.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/YamlDocument.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 960cc7bd..9dd804cb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,69 @@ +# Dependabot version updates (security updates are enabled in the repository settings and ignore every limit below). +# Options: https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference +# - No commit-message prefix: a prefix produces "deps: ..." titles, which the PR policy title rule rejects. +# Dependabot pull requests are exempt from the title and CHANGELOG rules anyway (eng/ci/PullRequestPolicy.psm1). +# - Every ecosystem waits at least 7 days after a release (zizmor dependabot-cooldown; a cooldown never delays a +# security update). +# - Dependabot does not regenerate the SDK-implicit entries of packages.lock.json: check out its branch, run +# ./eng/Update-LockFiles.ps1 and push the result (eng/api/README.md#lock-files). +# - A dependency joins the first group it matches, so the specific groups come before the catch-all. version: 2 updates: - package-ecosystem: nuget directory: / schedule: interval: monthly + open-pull-requests-limit: 5 + labels: [ dependencies, .NET ] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 7 + semver-patch-days: 7 groups: + security-updates: + applies-to: security-updates + patterns: [ '*' ] + test-infrastructure: + patterns: + - 'xunit.v3*' + - 'Microsoft.Testing.*' + - 'Microsoft.CodeAnalysis.*.Testing' + - 'BenchmarkDotNet' + - 'YamlDotNet' + update-types: [ minor, patch ] + analyzers: + patterns: + - 'Meziantou.Analyzer' + - 'Microsoft.CodeAnalysis.BannedApiAnalyzers' + - 'Microsoft.CodeAnalysis.PublicApiAnalyzers' + update-types: [ minor, patch ] minor-and-patch: + patterns: [ '*' ] update-types: [ minor, patch ] ignore: - # The Roslyn pin moves together with RoslynComponentFloor (build error CESDK9002). + # The Roslyn pin moves together with RoslynComponentFloor (build error CESDK9002) and the SDK-bundled compiler: + # a component built against a newer Roslyn does not load in an older consumer compiler (CS9057). - dependency-name: Microsoft.CodeAnalysis.CSharp - dependency-name: Microsoft.CodeAnalysis.CSharp.Workspaces + - dependency-name: Microsoft.CodeAnalysis.Analyzers + # SDK-implicit packages recorded in the lock files move only with global.json (eng/api/README.md#lock-files). + - dependency-name: Microsoft.NET.ILLink.Tasks + - dependency-name: Microsoft.DotNet.ILCompiler + - dependency-name: 'runtime.*.Microsoft.DotNet.ILCompiler' + + - package-ecosystem: dotnet-sdk + directory: / + schedule: + interval: monthly + labels: [ dependencies, .NET ] + cooldown: + default-days: 7 + semver-major-days: 30 + ignore: + # A new .NET major is a deliberate migration (TargetFramework, AnalysisLevel pin, lock files), not a bump. + - dependency-name: '*' + update-types: [ 'version-update:semver-major' ] - package-ecosystem: github-actions directories: @@ -18,6 +71,10 @@ updates: - /.github/actions/* schedule: interval: monthly + labels: [ dependencies, ci ] + cooldown: + # GitHub Actions supports default-days only (no SemVer-bump days). + default-days: 7 groups: actions: update-types: [ minor, patch ] diff --git a/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj index e2512499..47dd0d7a 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj +++ b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj @@ -11,7 +11,10 @@ - + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/DependabotConfigurationTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/DependabotConfigurationTests.cs new file mode 100644 index 00000000..faeae296 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/DependabotConfigurationTests.cs @@ -0,0 +1,193 @@ +using System.Globalization; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// .github/dependabot.yml (audit register PR-CQ-07): every ecosystem waits before proposing a fresh release, +/// the Roslyn pin and the SDK-implicit packages never move on their own, and titles stay compatible with the pull +/// request policy. https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference +/// +public sealed class DependabotConfigurationTests +{ + private const string ConfigurationPath = ".github/dependabot.yml"; + private const int MinimumCooldownDays = 7; + + /// Packages that move only with RoslynComponentFloor (CESDK9002) or with global.json. + private static readonly string[] s_pinnedPackages = + [ + "Microsoft.CodeAnalysis.CSharp", + "Microsoft.CodeAnalysis.CSharp.Workspaces", + "Microsoft.CodeAnalysis.Analyzers", + "Microsoft.NET.ILLink.Tasks", + "Microsoft.DotNet.ILCompiler", + "runtime.*.Microsoft.DotNet.ILCompiler" + ]; + + private static readonly string[] s_cooldownKeys = + ["default-days", "semver-major-days", "semver-minor-days", "semver-patch-days"]; + + [Fact] + public void Every_ecosystem_has_a_cooldown_of_at_least_seven_days() + { + List problems = []; + foreach (YamlMappingNode update in Updates()) + { + string ecosystem = YamlDocument.Scalar(update, "package-ecosystem") ?? "?"; + YamlNode? cooldown = YamlDocument.Child(update, "cooldown"); + if (cooldown is null) + { + problems.Add($"{ecosystem}: no cooldown"); + continue; + } + + if (YamlDocument.Scalar(cooldown, "default-days") is null) + { + problems.Add($"{ecosystem}: no default-days"); + } + + foreach (string key in s_cooldownKeys) + { + string? value = YamlDocument.Scalar(cooldown, key); + if (value is not null && (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int days) || days < MinimumCooldownDays)) + { + problems.Add($"{ecosystem}: {key} is {value}"); + } + } + } + + Assert.True(problems.Count == 0, + $"Every Dependabot ecosystem needs a cooldown of at least {MinimumCooldownDays} days (zizmor dependabot-cooldown): {string.Join("; ", problems)}"); + } + + [Fact] + public void Roslyn_pins_and_sdk_implicit_packages_are_ignored() + { + YamlMappingNode nuget = Ecosystem("nuget"); + HashSet ignored = new(StringComparer.Ordinal); + foreach (YamlMappingNode rule in YamlDocument.Mappings(nuget, "ignore")) + { + // A whole-package ignore has no update-types: a partial ignore would still let some bumps through. + Assert.True(YamlDocument.Child(rule, "update-types") is null && YamlDocument.Child(rule, "versions") is null, + $"The nuget ignore rule for '{YamlDocument.Scalar(rule, "dependency-name")}' must ignore every version."); + ignored.Add(YamlDocument.Scalar(rule, "dependency-name") ?? ""); + } + + foreach (string package in s_pinnedPackages) + { + Assert.True(ignored.Contains(package), $"Dependabot must ignore '{package}' ({ConfigurationPath})."); + } + } + + [Fact] + public void Roslyn_ignores_cover_every_package_pinned_to_the_roslyn_floor() + { + XDocument roslynProps = XDocument.Load(RepositoryFile.FullPath("eng/RoslynComponent.props")); + string floor = Assert.Single(roslynProps.Descendants("RoslynComponentFloor")).Value.Trim(); + XDocument packages = XDocument.Load(RepositoryFile.FullPath("Directory.Packages.props")); + + List pinnedToFloor = []; + foreach (XElement version in packages.Descendants("PackageVersion")) + { + string id = (string?) version.Attribute("Include") ?? ""; + if (id.StartsWith("Microsoft.CodeAnalysis.", StringComparison.Ordinal) + && string.Equals((string?) version.Attribute("Version"), floor, StringComparison.Ordinal)) + { + pinnedToFloor.Add(id); + } + } + + Assert.NotEmpty(pinnedToFloor); + foreach (string id in pinnedToFloor) + { + Assert.True(Array.IndexOf(s_pinnedPackages, id) >= 0, + $"'{id}' is pinned to RoslynComponentFloor {floor} in Directory.Packages.props: add a Dependabot ignore for it."); + } + } + + [Fact] + public void Dotnet_sdk_ecosystem_ignores_major_updates() + { + YamlMappingNode sdk = Ecosystem("dotnet-sdk"); + Assert.Equal("/", YamlDocument.Scalar(sdk, "directory")); + + bool ignoresMajor = false; + foreach (YamlMappingNode rule in YamlDocument.Mappings(sdk, "ignore")) + { + if (string.Equals(YamlDocument.Scalar(rule, "dependency-name"), "*", StringComparison.Ordinal) + && YamlDocument.Scalars(rule, "update-types").Contains("version-update:semver-major", StringComparer.Ordinal)) + { + ignoresMajor = true; + } + } + + Assert.True(ignoresMajor, "The dotnet-sdk ecosystem must ignore semver-major updates: a new .NET major is a migration."); + } + + [Fact] + public void Github_actions_updates_cover_the_composite_action_directories() + { + YamlMappingNode actions = Ecosystem("github-actions"); + IReadOnlyList directories = YamlDocument.Scalars(actions, "directories"); + + Assert.Contains("/", directories, StringComparer.Ordinal); + Assert.Contains("/.github/actions/*", directories, StringComparer.Ordinal); + Assert.True(Directory.Exists(RepositoryFile.FullPath(".github/actions")), + "The composite action folder moved: update the github-actions directories."); + } + + [Fact] + public void No_ecosystem_sets_a_commit_message_prefix() + { + foreach (YamlMappingNode update in Updates()) + { + YamlNode? commitMessage = YamlDocument.Child(update, "commit-message"); + Assert.True(commitMessage is null || YamlDocument.Scalar(commitMessage, "prefix") is null, + $"{YamlDocument.Scalar(update, "package-ecosystem")} sets commit-message.prefix: 'deps: ...' titles break the pull request title rule."); + Assert.Null(YamlDocument.Child(update, "insecure-external-code-execution")); + } + } + + [Fact] + public void Specific_nuget_groups_come_before_the_catch_all_group() + { + IReadOnlyList groups = YamlDocument.KeysOf(YamlDocument.Child(Ecosystem("nuget"), "groups")); + + int catchAll = -1; + for (int i = 0; i < groups.Count; i++) + { + YamlNode? group = YamlDocument.Child(YamlDocument.Child(Ecosystem("nuget"), "groups"), groups[i]); + bool versionUpdates = !string.Equals(YamlDocument.Scalar(group, "applies-to"), "security-updates", + StringComparison.Ordinal); + if (versionUpdates && YamlDocument.Scalars(group, "patterns") is ["*"]) + { + catchAll = i; + } + } + + // Dependabot puts a dependency in the first group it matches. + Assert.Equal(groups.Count - 1, catchAll); + } + + private static List Updates() + { + IReadOnlyList updates = YamlDocument.Mappings(YamlDocument.Load(ConfigurationPath).Root, "updates"); + Assert.NotEmpty(updates); + return [.. updates]; + } + + private static YamlMappingNode Ecosystem(string name) + { + List matches = []; + foreach (YamlMappingNode update in Updates()) + { + if (string.Equals(YamlDocument.Scalar(update, "package-ecosystem"), name, StringComparison.Ordinal)) + { + matches.Add(update); + } + } + + return Assert.Single(matches); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/RepositoryFile.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/RepositoryFile.cs new file mode 100644 index 00000000..14609900 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/RepositoryFile.cs @@ -0,0 +1,59 @@ +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// Reads committed files by repository-relative path (forward slashes). +internal static class RepositoryFile +{ + /// The absolute path of a repository-relative path. + public static string FullPath(string relativePath) + { + return Path.Combine(RepositoryRoot.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + /// The text of a committed file; fails the test when it does not exist. + public static string ReadText(string relativePath) + { + string fullPath = FullPath(relativePath); + Assert.True(File.Exists(fullPath), $"'{relativePath}' does not exist."); + return File.ReadAllText(fullPath); + } + + /// The lines of a committed file, without line terminators. + public static string[] ReadLines(string relativePath) + { + return ReadText(relativePath).ReplaceLineEndings("\n").Split('\n'); + } + + /// + /// True when the repository-relative path exists with exactly this case for every segment. Windows file systems + /// ignore case, GitHub does not, so alone would accept a wrong spelling. + /// + public static bool ExistsWithExactCase(string relativePath, out bool isDirectory) + { + isDirectory = false; + string current = RepositoryRoot.Path; + string[] segments = relativePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < segments.Length; i++) + { + string? match = null; + foreach (string entry in Directory.EnumerateFileSystemEntries(current)) + { + if (string.Equals(Path.GetFileName(entry), segments[i], StringComparison.Ordinal)) + { + match = entry; + } + } + + if (match is null) + { + return false; + } + + current = match; + } + + isDirectory = Directory.Exists(current); + return true; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/YamlDocument.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/YamlDocument.cs new file mode 100644 index 00000000..e35dcc97 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/YamlDocument.cs @@ -0,0 +1,278 @@ +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// A committed YAML file (workflow, Dependabot configuration, issue form) read through YamlDotNet's representation +/// model. Every scalar stays the string written in the file: the workflow key on stays "on" (no YAML 1.1 +/// boolean resolution) and 'true' and true both read as "true", which is how GitHub compares them. +/// +internal sealed class YamlDocument +{ + private YamlDocument(string relativePath, string text, YamlMappingNode root) + { + RelativePath = relativePath; + Text = text; + Root = root; + } + + /// The repository-relative path, with forward slashes. + public string RelativePath + { + get; + } + + /// The raw text, for rules about comments or exact spelling. + public string Text + { + get; + } + + /// The top-level mapping. + public YamlMappingNode Root + { + get; + } + + /// The jobs of a workflow, in file order, keyed by job id. + public IReadOnlyList> Jobs + { + get + { + List> jobs = []; + if (Child(Root, "jobs") is YamlMappingNode mapping) + { + foreach (KeyValuePair entry in mapping.Children) + { + if (entry.Value is YamlMappingNode job) + { + jobs.Add(new KeyValuePair(((YamlScalarNode) entry.Key).Value ?? "", job)); + } + } + } + + return jobs; + } + } + + /// The event names of the workflow's on key, whether it is a scalar, a sequence or a mapping. + public IReadOnlyList Triggers + { + get + { + YamlNode? on = Child(Root, "on"); + return on switch + { + YamlMappingNode mapping => KeysOf(mapping), + _ => ScalarsOf(on) + }; + } + } + + /// Loads a file below the repository root. + public static YamlDocument Load(string relativePath) + { + return Parse(relativePath, RepositoryFile.ReadText(relativePath)); + } + + /// Parses YAML text; the path is only used in messages. + public static YamlDocument Parse(string relativePath, string text) + { + YamlStream stream = new(); + using (StringReader reader = new(text)) + { + stream.Load(reader); + } + + Assert.True(stream.Documents.Count == 1, $"'{relativePath}' must hold exactly one YAML document."); + YamlMappingNode? root = stream.Documents[0].RootNode as YamlMappingNode; + Assert.True(root is not null, $"The root of '{relativePath}' must be a mapping."); + return new YamlDocument(relativePath, text, root); + } + + /// The job with the given id; fails the test when it does not exist. + public YamlMappingNode Job(string id) + { + YamlMappingNode? found = null; + foreach (KeyValuePair job in Jobs) + { + if (string.Equals(job.Key, id, StringComparison.Ordinal)) + { + found = job.Value; + } + } + + Assert.True(found is not null, $"'{RelativePath}' has no job '{id}'."); + return found; + } + + /// The value of an event under on, or (also for a scalar or sequence form). + public YamlNode? Trigger(string name) + { + return Child(Child(Root, "on"), name); + } + + /// The value of when is a mapping that holds it. + public static YamlNode? Child(YamlNode? node, string key) + { + if (node is not YamlMappingNode mapping) + { + return null; + } + + foreach (KeyValuePair entry in mapping.Children) + { + if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal)) + { + return entry.Value; + } + } + + return null; + } + + /// The scalar value of , or when it is absent or not a scalar. + public static string? Scalar(YamlNode? node, string key) + { + return Child(node, key) is YamlScalarNode scalar ? scalar.Value : null; + } + + /// The scalar values of : one for a scalar, every scalar item for a sequence. + public static IReadOnlyList Scalars(YamlNode? node, string key) + { + return ScalarsOf(Child(node, key)); + } + + /// The scalar items of a scalar or sequence node. + public static IReadOnlyList ScalarsOf(YamlNode? node) + { + List values = []; + switch (node) + { + case YamlScalarNode scalar when scalar.Value is not null: + values.Add(scalar.Value); + break; + case YamlSequenceNode sequence: + foreach (YamlNode item in sequence.Children) + { + if (item is YamlScalarNode { Value: not null } itemScalar) + { + values.Add(itemScalar.Value); + } + } + + break; + } + + return values; + } + + /// The mapping items of the sequence at . + public static IReadOnlyList Mappings(YamlNode? node, string key) + { + List mappings = []; + if (Child(node, key) is YamlSequenceNode sequence) + { + foreach (YamlNode item in sequence.Children) + { + if (item is YamlMappingNode mapping) + { + mappings.Add(mapping); + } + } + } + + return mappings; + } + + /// The keys of a mapping, in file order. + public static IReadOnlyList KeysOf(YamlNode? node) + { + List keys = []; + if (node is YamlMappingNode mapping) + { + foreach (KeyValuePair entry in mapping.Children) + { + if (entry.Key is YamlScalarNode { Value: not null } scalar) + { + keys.Add(scalar.Value); + } + } + } + + return keys; + } + + /// The steps of a job. + public static IReadOnlyList Steps(YamlMappingNode job) + { + return Mappings(job, "steps"); + } + + /// + /// The permissions of a workflow or job as scope → access. { } gives an empty map; a scalar such as + /// read-all is returned under the key *; when the key is absent. + /// + public static IReadOnlyDictionary? Permissions(YamlNode? owner) + { + YamlNode? permissions = Child(owner, "permissions"); + if (permissions is null) + { + return null; + } + + Dictionary scopes = new(StringComparer.Ordinal); + if (permissions is YamlScalarNode scalar) + { + scopes["*"] = scalar.Value ?? ""; + return scopes; + } + + foreach (string scope in KeysOf(permissions)) + { + scopes[scope] = Scalar(permissions, scope) ?? ""; + } + + return scopes; + } + + /// The action reference of a step (uses:), without its trailing comment. + public static string? Uses(YamlMappingNode step) + { + return Scalar(step, "uses"); + } + + /// True when the step uses the action at (for example actions/checkout). + public static bool UsesAction(YamlMappingNode step, string actionPath) + { + string? uses = Uses(step); + return uses is not null && uses.StartsWith(actionPath + "@", StringComparison.Ordinal); + } + + /// Collapses runs of whitespace, so folded and literal block scalars compare by content. + public static string NormalizeWhitespace(string value) + { + return string.Join(' ', value.Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries)); + } + + /// The 1-based line of the first occurrence of in the file, or 0. + public int LineOf(string text) + { + int index = Text.IndexOf(text, StringComparison.Ordinal); + if (index < 0) + { + return 0; + } + + int line = 1; + for (int i = 0; i < index; i++) + { + if (Text[i] == '\n') + { + line++; + } + } + + return line; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index 7e05c943..a9e57516 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -22,6 +22,7 @@ this project only reads committed files. It never builds, packs, restores or sta | `LockFiles/` | `LockFileTests` mirror the structural checks of `eng/Update-LockFiles.ps1` over the committed `packages.lock.json` files. | | `PublicApi/` | PublicAPI files, `CompatibilitySuppressions.xml` and the `eng/api/*.txt` lists: file shape, declared breaks, Client-induced breaks, enum contracts. | | `Workflows/` | `WorkflowContractTests` parse `.github/workflows/*.yml` and the composite actions with YamlDotNet and freeze the CI contract; `CoverageBaselineTests`, `BuildInfoSchemaTests` and `ClientCanaryScriptTests` check the files and scripts of `eng/ci/` that CI runs. | +| `Governance/` | Pull request policy script and workflow, CodeQL/Scorecard/zizmor/dependency-submission/scheduled-health workflow invariants, Dependabot, CODEOWNERS, SECURITY.md, issue forms and repository-settings payloads. | Later work adds one folder per contract (for example `Documentation/`, `Workflows/`, `Qualification/`). @@ -150,6 +151,13 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow - The advisory client canary writes the fields of its report schema, isolates the branch package and never fails the run because the Client breaks (`Client_canary_report_schema_requires_exactly_the_fields_the_script_writes`, `Client_canary_isolates_its_packages_and_never_gates`). +- Every Dependabot ecosystem waits at least seven days before proposing a release, the Roslyn pin and the SDK-implicit + packages never move on their own, the `dotnet-sdk` ecosystem ignores new majors, the composite action is updated with + the workflows, and no ecosystem sets a commit prefix (`DependabotConfigurationTests`: + `Every_ecosystem_has_a_cooldown_of_at_least_seven_days`, `Roslyn_pins_and_sdk_implicit_packages_are_ignored`, + `Roslyn_ignores_cover_every_package_pinned_to_the_roslyn_floor`, `Dotnet_sdk_ecosystem_ignores_major_updates`, + `Github_actions_updates_cover_the_composite_action_directories`, `No_ecosystem_sets_a_commit_message_prefix`, + `Specific_nuget_groups_come_before_the_catch_all_group`). ## Run the tests From 4ee334e45e3814379ff0d38b063792c204bd5238 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 03:17:20 +0200 Subject: [PATCH 064/199] Enforce the pull request title and changelog policy Add the second required check, "PR policy" (shared-contracts 1.2, 1.12; audit register PR-CQ-34). The title and CHANGELOG rules were advisory (CONTRIBUTING, CodeRabbit warning mode) and only the tag run enforced a CHANGELOG section. pull-request-ci.yml does not run on "edited", so a title fix would need a full Windows run: the check is a separate, cheap Ubuntu workflow that also runs on title and description edits, drafts included, with no path filter and no job condition (a skipped job would satisfy a required check). - eng/ci/PullRequestPolicy.psm1 holds pure rules with stable ids: TitleLength (1-72 text elements), TitleNoTrailingPeriod, TitleNoConventionalPrefix (also rejects "Area:" prefixes), TitleStartsUppercase, TitleImperative (-ed/-ing/third-person heuristic with an allowlist of real verbs) and ChangelogEntry (libs/, src/, analyzers/, source-generators/, native/ changes, lock files excluded, unless CHANGELOG.md changes or the description carries ). Matching is ordinal, case-sensitive, with regex timeouts. Pull requests authored by dependabot[bot] are exempt; the repository-specific values sit in one constants block so the CheatEngine.Client twin changes only those lines. - eng/ci/Test-PullRequestPolicy.ps1 reads the pull request only from environment variables, diffs base...head with --no-renames (moving a file out of libs/ still counts), writes a rule table to the job summary, emits one escaped ::error annotation per failed rule and never prints the description. The repository tests now start pwsh for this: PullRequestPolicyFixture evaluates 35 vectors (real titles of #10, #83 and #86 included) in one process through JSON files, and the entry script is run end to end with the GitHub file-command variables removed from its environment. The project README and comment record this single exception to "never starts a process". --- .github/workflows/pr-policy.yml | 50 ++++ CheatEngine.SDK.slnx | 3 + eng/ci/PullRequestPolicy.psm1 | 216 ++++++++++++++++++ eng/ci/Test-PullRequestPolicy.ps1 | 126 ++++++++++ .../CheatEngine.SDK.Repository.Tests.csproj | 5 +- .../Governance/GovernanceWorkflows.cs | 46 ++++ .../Governance/PolicyRuleResult.cs | 4 + .../Governance/PullRequestPolicyCase.cs | 10 + .../Governance/PullRequestPolicyCases.cs | 103 +++++++++ .../Governance/PullRequestPolicyFixture.cs | 80 +++++++ .../PullRequestPolicyScriptTests.cs | 177 ++++++++++++++ .../PullRequestPolicyWorkflowTests.cs | 132 +++++++++++ .../Governance/PwshResult.cs | 8 + .../Governance/PwshScript.cs | 128 +++++++++++ .../Governance/TemporaryDirectory.cs | 39 ++++ .../README.md | 18 +- 16 files changed, 1142 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/pr-policy.yml create mode 100644 eng/ci/PullRequestPolicy.psm1 create mode 100644 eng/ci/Test-PullRequestPolicy.ps1 create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflows.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PolicyRuleResult.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyCase.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyCases.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyFixture.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyScriptTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyWorkflowTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PwshResult.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PwshScript.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/TemporaryDirectory.cs diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 00000000..ccfd250c --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,50 @@ +# Second required check, context "PR policy" (next to "CI / Gate"). Never rename the job, never add path filters, +# never move it into ci.yml, never switch to pull_request_target. It runs again on every title or description edit, +# so it stays cheap: Ubuntu, no .NET. The rules live in eng/ci/PullRequestPolicy.psm1 (tested by +# tests/CheatEngine.SDK.Repository.Tests/Governance); Dependabot pull requests are exempt. +# Drafts run it too: the check must exist before a pull request is marked ready for review. +name: PR policy + +on: + pull_request: + types: [ opened, edited, synchronize, reopened, ready_for_review ] + +# An edit or a push supersedes the previous evaluation of the same pull request. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + policy: + name: PR policy # the required check context; never rename + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # the changelog rule diffs base...head, which needs the merge base + persist-credentials: false + + # Pull-request values reach the script only through the environment, never through template expansion in run:. + # The author is compared inside the script (not with github.actor), so a maintainer's push to a Dependabot branch + # is still exempt and nothing here is a bot-identity condition. + - name: Check the title and the CHANGELOG entry + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + ./eng/ci/Test-PullRequestPolicy.ps1 + if ($LASTEXITCODE -ne 0) { + throw "The pull request policy failed with exit code $LASTEXITCODE; the annotations and the job summary name each rule." + } diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 40eb0b76..80233296 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -27,6 +27,7 @@ + @@ -58,8 +59,10 @@ + + diff --git a/eng/ci/PullRequestPolicy.psm1 b/eng/ci/PullRequestPolicy.psm1 new file mode 100644 index 00000000..8f01d754 --- /dev/null +++ b/eng/ci/PullRequestPolicy.psm1 @@ -0,0 +1,216 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Pure rules of the "PR policy" required check: pull request title shape and CHANGELOG entry. + +.DESCRIPTION + Test-PullRequestPolicy evaluates a pull request from its title, body, author login and changed paths, and returns one + result per rule. It reads no file, runs no git command and touches no environment variable, so the rules are tested + offline by tests/CheatEngine.SDK.Repository.Tests/Governance (PullRequestPolicyScriptTests), and the entry point + eng/ci/Test-PullRequestPolicy.ps1 only collects its inputs and reports. + + Rules (stable ids, asserted by the tests): + TitleLength 1 to 72 text elements after trimming (the squash subject on main is the title). + TitleNoTrailingPeriod no final period. + TitleNoConventionalPrefix no "type:", "type(scope):" or "type!:" prefix, and no "Area:" prefix either. + TitleStartsUppercase the first character is an uppercase letter. + TitleImperative the first word reads as an imperative verb (heuristic with an allowlist). + ChangelogEntry a change under a consumer-visible path (lock files excluded) also changes CHANGELOG.md, + unless the description contains the waiver marker . + + Pull requests opened by Dependabot pass every rule: their titles are generated and their lock-file changes under + libs/ are not consumer-visible on their own. + + Every match is ordinal and case-sensitive (git paths are case-exact), with a regex timeout. +#> + +Set-StrictMode -Version Latest + +# Repository-specific constants. The CheatEngine.Client twin of this module changes only this block. +$ChangelogPathPattern = '^(libs|src|analyzers|source-generators|native)/' +$ChangelogExcludedPattern = '(^|/)packages\.lock\.json$' +$ChangelogFile = 'CHANGELOG.md' +$ChangelogWaiverPattern = '' +$DependabotLogin = 'dependabot[bot]' + +# Shared rules (both repositories, CONTRIBUTING.md "Commits"). +$MaximumTitleLength = 72 +$ConventionalPrefixPattern = '^\w+(\([^)]*\))?!?:\s' +$NonImperativeFirstWords = @('WIP', 'Draft', 'Misc', 'Various', 'Minor') +# Imperative verbs that the suffix heuristic (-ed, -ing, third-person -s) would otherwise reject. +$ImperativeFirstWords = @( + 'Alias', 'Bias', 'Bleed', 'Breed', 'Bring', 'Canvas', 'Embed', 'Exceed', 'Feed', 'Focus', 'Heed', 'Need', 'Ping', + 'Proceed', 'Ring', 'Seed', 'Shed', 'Shred', 'Sing', 'Speed', 'Spring', 'Sting', 'String', 'Succeed', 'Swing', 'Wring' +) +$MaximumListedPaths = 10 +$RegexTimeout = [TimeSpan]::FromSeconds(1) + +function Test-RegexMatch { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $InputText, + [Parameter(Mandatory)] [string] $Pattern + ) + + return [regex]::IsMatch($InputText, $Pattern, [System.Text.RegularExpressions.RegexOptions]::CultureInvariant, $RegexTimeout) +} + +function ConvertTo-RuleResult { + param( + [Parameter(Mandatory)] [string] $Rule, + [Parameter(Mandatory)] [bool] $Passed, + [Parameter(Mandatory)] [string] $Message + ) + + return [pscustomobject]@{ Rule = $Rule; Passed = $Passed; Message = $Message } +} + +function Get-FirstWord { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $Title + ) + + $word = ($Title -split '\s+', 2)[0] + return $word.TrimEnd(':', ',', ';', '.', '!', '?') +} + +function Test-ImperativeWord { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $Word + ) + + foreach ($denied in $NonImperativeFirstWords) { + if ([string]::Equals($Word, $denied, [StringComparison]::OrdinalIgnoreCase)) { + return "'$Word' does not say what the change does; start with an imperative verb such as Add, Fix or Remove." + } + } + + foreach ($allowed in $ImperativeFirstWords) { + if ([string]::Equals($Word, $allowed, [StringComparison]::OrdinalIgnoreCase)) { + return $null + } + } + + $lower = $Word.ToLowerInvariant() + if ($lower.Length -gt 3 -and $lower.EndsWith('ed', [StringComparison]::Ordinal)) { + return "'$Word' reads as past tense; use the imperative mood (for example 'Add', not 'Added')." + } + + if ($lower.Length -gt 4 -and $lower.EndsWith('ing', [StringComparison]::Ordinal)) { + return "'$Word' reads as a gerund; use the imperative mood (for example 'Add', not 'Adding')." + } + + if ($lower.Length -gt 2 -and $lower[-1] -ceq 's' -and $lower[-2] -cne 's' -and [char]::IsLetter($lower[-2])) { + return "'$Word' reads as third person; use the imperative mood (for example 'Add', not 'Adds')." + } + + return $null +} + +function Test-PullRequestTitle { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $Title + ) + + $trimmed = $Title.Trim() + $length = [System.Globalization.StringInfo]::new($trimmed).LengthInTextElements + $lengthOk = $length -ge 1 -and $length -le $MaximumTitleLength + ConvertTo-RuleResult -Rule 'TitleLength' -Passed $lengthOk -Message $( + if ($length -eq 0) { 'The title is empty.' } + elseif ($lengthOk) { "$length characters (at most $MaximumTitleLength)." } + else { "The title has $length characters; shorten it to at most $MaximumTitleLength (it becomes the commit subject on main)." } + ) + + if ($length -eq 0) { + foreach ($rule in 'TitleNoTrailingPeriod', 'TitleNoConventionalPrefix', 'TitleStartsUppercase', 'TitleImperative') { + ConvertTo-RuleResult -Rule $rule -Passed $true -Message 'Not evaluated: the title is empty (see TitleLength).' + } + + return + } + + $noPeriod = -not $trimmed.EndsWith('.', [StringComparison]::Ordinal) + ConvertTo-RuleResult -Rule 'TitleNoTrailingPeriod' -Passed $noPeriod -Message $( + if ($noPeriod) { 'No trailing period.' } else { 'Remove the trailing period.' } + ) + + $noPrefix = -not (Test-RegexMatch -InputText $trimmed -Pattern $ConventionalPrefixPattern) + ConvertTo-RuleResult -Rule 'TitleNoConventionalPrefix' -Passed $noPrefix -Message $( + if ($noPrefix) { 'No type or area prefix.' } + else { "Remove the 'type:' or 'Area:' prefix; describe the change with an imperative sentence instead." } + ) + + $uppercase = Test-RegexMatch -InputText $trimmed -Pattern '^\p{Lu}' + ConvertTo-RuleResult -Rule 'TitleStartsUppercase' -Passed $uppercase -Message $( + if ($uppercase) { 'Starts with an uppercase letter.' } else { 'Start the title with an uppercase letter.' } + ) + + $problem = Test-ImperativeWord -Word (Get-FirstWord -Title $trimmed) + ConvertTo-RuleResult -Rule 'TitleImperative' -Passed ($null -eq $problem) -Message $( + if ($null -eq $problem) { 'The first word reads as an imperative verb.' } else { $problem } + ) +} + +function Test-ChangelogEntry { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $Body, + [AllowEmptyCollection()] [Parameter(Mandatory)] [string[]] $ChangedFile + ) + + $visible = [System.Collections.Generic.List[string]]::new() + foreach ($path in $ChangedFile) { + if ((Test-RegexMatch -InputText $path -Pattern $ChangelogPathPattern) -and + -not (Test-RegexMatch -InputText $path -Pattern $ChangelogExcludedPattern)) { + $visible.Add($path) + } + } + + if ($visible.Count -eq 0) { + return ConvertTo-RuleResult -Rule 'ChangelogEntry' -Passed $true -Message 'No consumer-visible path changed.' + } + + if ($ChangedFile -ccontains $ChangelogFile) { + return ConvertTo-RuleResult -Rule 'ChangelogEntry' -Passed $true -Message "$ChangelogFile changes with the consumer-visible paths." + } + + if (Test-RegexMatch -InputText $Body -Pattern $ChangelogWaiverPattern) { + return ConvertTo-RuleResult -Rule 'ChangelogEntry' -Passed $true -Message 'The description waives the CHANGELOG entry ().' + } + + $listed = @($visible | Select-Object -First $MaximumListedPaths | ForEach-Object { "``$_``" }) + $more = if ($visible.Count -gt $MaximumListedPaths) { " and $($visible.Count - $MaximumListedPaths) more" } else { '' } + return ConvertTo-RuleResult -Rule 'ChangelogEntry' -Passed $false -Message ( + "Consumer-visible paths changed without $ChangelogFile ($($listed -join ', ')$more). " + + "Add an entry under '## [Unreleased]' in $ChangelogFile, or, when no consumer can observe the change, " + + 'put in the pull request description with the reason.') +} + +<# +.SYNOPSIS + Evaluates every PR policy rule and returns one [pscustomobject] @{ Rule; Passed; Message } per rule, in a fixed order. +#> +function Test-PullRequestPolicy { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [AllowEmptyString()] [AllowNull()] [string] $Title = '', + [AllowEmptyString()] [AllowNull()] [string] $Body = '', + [AllowEmptyString()] [AllowNull()] [string] $Author = '', + [AllowEmptyCollection()] [AllowNull()] [string[]] $ChangedFile = @() + ) + + $rules = @('TitleLength', 'TitleNoTrailingPeriod', 'TitleNoConventionalPrefix', 'TitleStartsUppercase', 'TitleImperative', + 'ChangelogEntry') + if ([string]::Equals($Author, $DependabotLogin, [StringComparison]::Ordinal)) { + foreach ($rule in $rules) { + ConvertTo-RuleResult -Rule $rule -Passed $true -Message 'Dependabot pull request: title and changelog rules exempt.' + } + + return + } + + $files = @($ChangedFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + Test-PullRequestTitle -Title ([string] $Title) + Test-ChangelogEntry -Body ([string] $Body) -ChangedFile $files +} + +Export-ModuleMember -Function Test-PullRequestPolicy diff --git a/eng/ci/Test-PullRequestPolicy.ps1 b/eng/ci/Test-PullRequestPolicy.ps1 new file mode 100644 index 00000000..ec4bdcb2 --- /dev/null +++ b/eng/ci/Test-PullRequestPolicy.ps1 @@ -0,0 +1,126 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Entry point of the "PR policy" required check (.github/workflows/pr-policy.yml): checks the pull request title and + the CHANGELOG entry with the rules of eng/ci/PullRequestPolicy.psm1. + +.DESCRIPTION + The workflow passes every pull-request value through environment variables (never through template expansion in + the script text), so the defaults of the parameters read them. The changed paths come from + `git diff --name-only --no-renames -z ...` (a rename out of libs/ still counts as a change there), or + from -ChangedFilesPath, one path per line, which the repository tests and local runs use. + + The script writes a rule table to the job summary when GITHUB_STEP_SUMMARY is set, emits one `::error` annotation per + failed rule, and exits 1 when any rule failed. It never prints the pull request body. + +.PARAMETER Title + Pull request title. Defaults to $env:PR_TITLE. + +.PARAMETER Body + Pull request description. Defaults to $env:PR_BODY. Only searched for the waiver marker. + +.PARAMETER Author + Login of the pull request author. Defaults to $env:PR_AUTHOR. Dependabot pull requests are exempt. + +.PARAMETER BaseSha + Base commit of the pull request. Defaults to $env:BASE_SHA. + +.PARAMETER HeadSha + Head commit of the pull request. Defaults to $env:HEAD_SHA. + +.PARAMETER ChangedFilesPath + A file with the changed paths, one per line. When given, git is not called. + +.EXAMPLE + ./eng/ci/Test-PullRequestPolicy.ps1 -Title 'Add CodeQL analysis' -ChangedFilesPath changed.txt +#> +[CmdletBinding()] +param( + [string] $Title = $env:PR_TITLE, + [string] $Body = $env:PR_BODY, + [string] $Author = $env:PR_AUTHOR, + [string] $BaseSha = $env:BASE_SHA, + [string] $HeadSha = $env:HEAD_SHA, + [string] $ChangedFilesPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'PullRequestPolicy.psm1') -Force + +# Workflow command data must escape %, CR and LF, or a crafted path could start a second command on a new line. +# https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands +function ConvertTo-WorkflowCommandData { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $Text + ) + + return $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') +} + +function ConvertTo-MarkdownCell { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $Text + ) + + return $Text.Replace('|', '\|').Replace("`r", ' ').Replace("`n", ' ') +} + +function Get-ChangedFile { + param( + [string] $ListPath, + [string] $Base, + [string] $Head + ) + + if ($ListPath) { + return @(Get-Content -LiteralPath $ListPath -Encoding utf8 | Where-Object { $_ }) + } + + foreach ($sha in @($Base, $Head)) { + if (-not [regex]::IsMatch([string] $sha, '^[0-9a-f]{40}([0-9a-f]{24})?$')) { + throw "BASE_SHA and HEAD_SHA must be full commit ids (got '$sha'). The workflow passes github.event.pull_request.base.sha and head.sha." + } + } + + $repositoryRoot = Split-Path -Path $PSScriptRoot -Parent | Split-Path -Parent + $output = & git -C $repositoryRoot diff --name-only --no-renames -z "$Base...$Head" + if ($LASTEXITCODE -ne 0) { + throw "git diff $Base...$Head failed with exit code $LASTEXITCODE. The checkout needs fetch-depth: 0 for the merge base." + } + + return @((@($output) -join '') -split "`0" | Where-Object { $_ }) +} + +$changedFiles = @(Get-ChangedFile -ListPath $ChangedFilesPath -Base $BaseSha -Head $HeadSha) +$results = @(Test-PullRequestPolicy -Title $Title -Body $Body -Author $Author -ChangedFile $changedFiles) +$failures = @($results | Where-Object { -not $_.Passed }) + +if ($env:GITHUB_STEP_SUMMARY) { + $lines = @('## PR policy', '', '| Rule | Result | Detail |', '| --- | --- | --- |') + foreach ($result in $results) { + $verdict = if ($result.Passed) { 'Passed' } else { '**Failed**' } + $lines += "| $($result.Rule) | $verdict | $(ConvertTo-MarkdownCell -Text $result.Message) |" + } + + $lines += '' + $lines += "$($changedFiles.Count) changed path(s) evaluated." + $lines | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +} + +foreach ($result in $results) { + $state = if ($result.Passed) { 'pass' } else { 'FAIL' } + # Messages can quote paths; a raw line break in a path must not start a workflow command on its own line. + Write-Host "[$state] $(ConvertTo-WorkflowCommandData -Text "$($result.Rule): $($result.Message)")" +} + +foreach ($failure in $failures) { + Write-Host "::error title=PR policy::$(ConvertTo-WorkflowCommandData -Text "$($failure.Rule): $($failure.Message)")" +} + +if ($failures.Count -gt 0) { + exit 1 +} + +exit 0 diff --git a/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj index 47dd0d7a..44ef7874 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj +++ b/tests/CheatEngine.SDK.Repository.Tests/CheatEngine.SDK.Repository.Tests.csproj @@ -2,8 +2,9 @@ diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflows.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflows.cs new file mode 100644 index 00000000..5b253f88 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflows.cs @@ -0,0 +1,46 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// The workflow files of the repository and the advisory governance workflows outside CI / Gate. +internal static class GovernanceWorkflows +{ + public const string PrPolicy = ".github/workflows/pr-policy.yml"; + public const string CodeQl = ".github/workflows/codeql.yml"; + public const string Scorecard = ".github/workflows/scorecard.yml"; + public const string ZizmorOnline = ".github/workflows/zizmor-online.yml"; + public const string DependencySubmission = ".github/workflows/dependency-submission.yml"; + public const string ScheduledHealth = ".github/workflows/scheduled-health.yml"; + + /// The governance workflows, which never join gate.needs (PR policy is its own required check). + public static readonly string[] All = [PrPolicy, CodeQl, Scorecard, ZizmorOnline, DependencySubmission, ScheduledHealth]; + + /// Every workflow file, repository-relative, sorted. + public static List AllWorkflowPaths() + { + List paths = []; + foreach (string pattern in (string[]) ["*.yml", "*.yaml"]) + { + foreach (string file in Directory.EnumerateFiles(RepositoryFile.FullPath(".github/workflows"), pattern)) + { + paths.Add(".github/workflows/" + Path.GetFileName(file)); + } + } + + paths.Sort(StringComparer.Ordinal); + return paths; + } + + /// The governance workflows that exist in the working tree (each later commit adds one). + public static List Existing() + { + List existing = []; + foreach (string path in All) + { + if (File.Exists(RepositoryFile.FullPath(path))) + { + existing.Add(path); + } + } + + return existing; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PolicyRuleResult.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PolicyRuleResult.cs new file mode 100644 index 00000000..7853c6ab --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PolicyRuleResult.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// One rule result reported by Test-PullRequestPolicy. +internal sealed record PolicyRuleResult(string Rule, bool Passed, string Message); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyCase.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyCase.cs new file mode 100644 index 00000000..0987f8b2 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyCase.cs @@ -0,0 +1,10 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// One pull request evaluated by eng/ci/PullRequestPolicy.psm1 and the rules expected to fail. +internal sealed record PullRequestPolicyCase( + string Name, + string Title, + string[] ChangedFiles, + string[] ExpectedFailures, + string Body = "", + string Author = "contributor"); diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyCases.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyCases.cs new file mode 100644 index 00000000..e1b8a749 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyCases.cs @@ -0,0 +1,103 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// Test vectors of the PR policy (audit register PR-CQ-34, shared-contracts §1.12). Every rule id appears in at least +/// one failing vector, and the titles of real pull requests of this repository are included. +/// +internal static class PullRequestPolicyCases +{ + public const string TitleLength = "TitleLength"; + public const string TitleNoTrailingPeriod = "TitleNoTrailingPeriod"; + public const string TitleNoConventionalPrefix = "TitleNoConventionalPrefix"; + public const string TitleStartsUppercase = "TitleStartsUppercase"; + public const string TitleImperative = "TitleImperative"; + public const string ChangelogEntry = "ChangelogEntry"; + + /// Every rule, in the order the module reports them. + public static readonly string[] Rules = + [TitleLength, TitleNoTrailingPeriod, TitleNoConventionalPrefix, TitleStartsUppercase, TitleImperative, ChangelogEntry]; + + private const string ScanningSource = "libs/CheatEngine.SDK.Engine/Scanning/A.cs"; + + public static readonly PullRequestPolicyCase[] All = + [ + new("imperative_title_with_changelog", "Add CodeQL analysis for C# and C/C++", ["libs/X/A.cs", "CHANGELOG.md"], []), + // The open audit-remediation vehicle pull request (#86). + new("vehicle_pr_title", "Remediate the 2026-09-22 audit and overhaul CI/CD", [".github/workflows/ci.yml"], []), + new("revert_title", "Revert \"Remove unavailable Sonar CI integration\"", ["docs/README.md"], []), + new("allowlisted_first_word", "Embed the SBOM in the package", [".github/x.yml"], []), + new("allowlisted_s_ending_verb", "Focus the scan on committed files", [".github/x.yml"], []), + new("exactly_72_characters", "Add " + new string('x', 68), ["README.md"], []), + new("exactly_73_characters", "Add " + new string('x', 69), ["README.md"], [TitleLength]), + // 72 text elements, 140 UTF-16 code units: the limit counts what a reader sees. + new("combining_characters_count_as_one", "Add " + string.Concat(Enumerable.Repeat("é", 68)), ["README.md"], []), + new("surrounding_whitespace_is_trimmed", " Fix CI validation findings ", ["README.md"], []), + new("trailing_period", "Fix CI validation findings.", ["README.md"], [TitleNoTrailingPeriod]), + new("conventional_prefix", "feat: add codeql", ["README.md"], [TitleNoConventionalPrefix, TitleStartsUppercase]), + new("scoped_breaking_prefix", "Fix(ci)!: tidy", ["README.md"], [TitleNoConventionalPrefix]), + // PR #83 used an area prefix, which the no-prefix rule also rejects. + new("historical_area_prefix", "SDK: finalize runtime ownership and scan contracts", ["README.md"], + [TitleNoConventionalPrefix]), + new("past_tense", "Added CodeQL", ["README.md"], [TitleImperative]), + new("gerund", "Adding CodeQL", ["README.md"], [TitleImperative]), + new("third_person", "Adds CodeQL", ["README.md"], [TitleImperative]), + new("wip_marker", "WIP audit work", ["README.md"], [TitleImperative]), + new("lowercase_start", "fix CI", ["README.md"], [TitleStartsUppercase]), + new("empty_title", "", ["README.md"], [TitleLength]), + new("whitespace_title", " ", ["README.md"], [TitleLength]), + new("libs_change_without_changelog", "Fix AOB outcome", [ScanningSource], [ChangelogEntry]), + new("waiver_marker", "Fix AOB outcome", [ScanningSource], [], + "Internal rename only.\n\n"), + new("waiver_marker_spacing", "Fix AOB outcome", [ScanningSource], [], ""), + new("waiver_marker_is_case_sensitive", "Fix AOB outcome", [ScanningSource], [ChangelogEntry], + ""), + new("lock_file_only_under_libs", "Refresh the lock file", ["libs/CheatEngine.SDK.Lua/packages.lock.json"], []), + new("native_readme_change", "Document the bridge exports", ["native/cheatengine-sdk-lua-bridge/README.md"], + [ChangelogEntry]), + new("build_assets_change", "Warn on x86 consumers", ["src/CheatEngine.SDK/build/CheatEngine.SDK.targets"], + [ChangelogEntry]), + new("analyzer_change", "Report the legacy registration pair", + ["analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticIds.cs"], [ChangelogEntry]), + new("generator_change", "Emit global-qualified names", + ["source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/Emitter.cs"], [ChangelogEntry]), + new("tests_and_workflows_only", "Repeat the threading tests weekly", + ["tests/CheatEngine.SDK.Lua.Tests/X.cs", ".github/workflows/ci.yml"], []), + new("case_sensitive_paths", "Fix a sample", ["Libs/X.cs"], []), + new("nested_changelog_does_not_count", "Fix AOB outcome", [ScanningSource, "docs/CHANGELOG.md"], [ChangelogEntry]), + // PR #10: generated title, lock-file and central version changes. + new("dependabot_exempt", "Bump the minor-and-patch group with 1 update", + ["libs/CheatEngine.SDK.Lua/packages.lock.json", "Directory.Packages.props"], [], Author: "dependabot[bot]"), + new("dependabot_exempt_even_for_a_broken_title", "bump stuff.", [ScanningSource], [], Author: "dependabot[bot]"), + new("dependabot_lookalike_login_is_not_exempt", "Bump xunit", [ScanningSource], [ChangelogEntry], + Author: "dependabot") + ]; + + /// The case names, for [MemberData]. + public static TheoryData Names + { + get + { + TheoryData names = []; + foreach (PullRequestPolicyCase policyCase in All) + { + names.Add(policyCase.Name); + } + + return names; + } + } + + /// The case with the given name. + public static PullRequestPolicyCase Get(string name) + { + foreach (PullRequestPolicyCase policyCase in All) + { + if (string.Equals(policyCase.Name, name, StringComparison.Ordinal)) + { + return policyCase; + } + } + + throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown PR policy case."); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyFixture.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyFixture.cs new file mode 100644 index 00000000..ac0561b5 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyFixture.cs @@ -0,0 +1,80 @@ +using System.Text; +using System.Text.Json; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// Evaluates every vector in a single pwsh process that imports +/// eng/ci/PullRequestPolicy.psm1. Inputs and outputs travel through UTF-8 JSON files, so no title or path is +/// ever interpolated into script text. +/// +public sealed class PullRequestPolicyFixture : IAsyncLifetime +{ + private readonly Dictionary> _results = new(StringComparer.Ordinal); + + /// The rule results of a case, in reported order. + internal IReadOnlyList ResultsOf(string caseName) + { + Assert.True(_results.TryGetValue(caseName, out List? results), + $"The policy module returned no result for '{caseName}'."); + return results; + } + + public async ValueTask InitializeAsync() + { + using TemporaryDirectory directory = new(); + string casesPath = directory.File("cases.json"); + string resultsPath = directory.File("results.json"); + + List> cases = []; + foreach (PullRequestPolicyCase policyCase in PullRequestPolicyCases.All) + { + cases.Add(new Dictionary(StringComparer.Ordinal) + { + ["name"] = policyCase.Name, + ["title"] = policyCase.Title, + ["body"] = policyCase.Body, + ["author"] = policyCase.Author, + ["changedFiles"] = policyCase.ChangedFiles + }); + } + + await File.WriteAllTextAsync(casesPath, JsonSerializer.Serialize(cases), new UTF8Encoding(false), + TestContext.Current.CancellationToken); + + string script = $$""" + Import-Module -Name {{PwshScript.Quote(RepositoryFile.FullPath("eng/ci/PullRequestPolicy.psm1"))}} -Force + $cases = Get-Content -Raw -Encoding utf8 -LiteralPath {{PwshScript.Quote(casesPath)}} | ConvertFrom-Json + $output = [ordered]@{} + foreach ($case in $cases) { + $results = @(Test-PullRequestPolicy -Title $case.title -Body $case.body -Author $case.author -ChangedFile @($case.changedFiles)) + $output[$case.name] = @($results | ForEach-Object { [ordered]@{ rule = $_.Rule; passed = $_.Passed; message = $_.Message } }) + } + $json = ConvertTo-Json -InputObject $output -Depth 5 + [System.IO.File]::WriteAllText({{PwshScript.Quote(resultsPath)}}, $json, [System.Text.UTF8Encoding]::new($false)) + """; + PwshResult run = await PwshScript.RunTextAsync(script); + Assert.True(run.ExitCode == 0, $"Evaluating the PR policy vectors failed: {run.Transcript}"); + + using JsonDocument document = JsonDocument.Parse(await File.ReadAllTextAsync(resultsPath, + TestContext.Current.CancellationToken)); + foreach (JsonProperty entry in document.RootElement.EnumerateObject()) + { + List results = []; + foreach (JsonElement result in entry.Value.EnumerateArray()) + { + results.Add(new PolicyRuleResult( + result.GetProperty("rule").GetString() ?? "", + result.GetProperty("passed").GetBoolean(), + result.GetProperty("message").GetString() ?? "")); + } + + _results[entry.Name] = results; + } + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyScriptTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyScriptTests.cs new file mode 100644 index 00000000..d42378e0 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyScriptTests.cs @@ -0,0 +1,177 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// The PR policy rules (eng/ci/PullRequestPolicy.psm1) against their vectors, and the entry point +/// eng/ci/Test-PullRequestPolicy.ps1 end to end: exit code, one annotation per failed rule, the job summary +/// table, and no echo of the pull request description (audit register PR-CQ-34). +/// +public sealed class PullRequestPolicyScriptTests(PullRequestPolicyFixture fixture) : IClassFixture +{ + private const string EntryScript = "eng/ci/Test-PullRequestPolicy.ps1"; + private const string ErrorAnnotation = "::error title=PR policy::"; + + public static TheoryData CaseNames => PullRequestPolicyCases.Names; + + [Theory] + [MemberData(nameof(CaseNames))] + public void Policy_verdict_matches_the_expected_rules(string caseName) + { + PullRequestPolicyCase policyCase = PullRequestPolicyCases.Get(caseName); + IReadOnlyList results = fixture.ResultsOf(caseName); + + List reported = []; + List failed = []; + foreach (PolicyRuleResult result in results) + { + reported.Add(result.Rule); + Assert.False(string.IsNullOrWhiteSpace(result.Message), $"{caseName}: rule {result.Rule} has no message."); + if (!result.Passed) + { + failed.Add(result.Rule); + } + } + + Assert.Equal(PullRequestPolicyCases.Rules, reported, StringComparer.Ordinal); + Assert.True(policyCase.ExpectedFailures.Order(StringComparer.Ordinal).SequenceEqual(failed.Order(StringComparer.Ordinal), StringComparer.Ordinal), + $"{caseName}: expected failures [{string.Join(", ", policyCase.ExpectedFailures)}], got [{string.Join(", ", failed)}]."); + } + + [Fact] + public void Every_rule_is_exercised_by_a_failing_vector() + { + HashSet exercised = new(StringComparer.Ordinal); + foreach (PullRequestPolicyCase policyCase in PullRequestPolicyCases.All) + { + exercised.UnionWith(policyCase.ExpectedFailures); + } + + Assert.Equal(PullRequestPolicyCases.Rules.Order(StringComparer.Ordinal), exercised.Order(StringComparer.Ordinal)); + } + + [Fact] + public void Dependabot_authored_pull_requests_are_exempt_from_every_rule() + { + foreach (string caseName in (string[]) ["dependabot_exempt", "dependabot_exempt_even_for_a_broken_title"]) + { + foreach (PolicyRuleResult result in fixture.ResultsOf(caseName)) + { + Assert.True(result.Passed, $"{caseName}: {result.Rule} failed."); + Assert.Equal("Dependabot pull request: title and changelog rules exempt.", result.Message); + } + } + + // Only the exact bot login is exempt; a user named "dependabot" is not. + Assert.Contains(fixture.ResultsOf("dependabot_lookalike_login_is_not_exempt"), static result => !result.Passed); + } + + [Fact] + public void Changelog_failure_names_the_paths_and_both_remedies() + { + PolicyRuleResult changelog = Assert.Single(fixture.ResultsOf("libs_change_without_changelog"), + static result => string.Equals(result.Rule, PullRequestPolicyCases.ChangelogEntry, StringComparison.Ordinal)); + + Assert.False(changelog.Passed); + Assert.Contains("`libs/CheatEngine.SDK.Engine/Scanning/A.cs`", changelog.Message, StringComparison.Ordinal); + Assert.Contains("## [Unreleased]", changelog.Message, StringComparison.Ordinal); + Assert.Contains("", changelog.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Entry_script_exits_non_zero_and_annotates_each_failed_rule() + { + using TemporaryDirectory directory = new(); + string changed = await WriteChangedFilesAsync(directory, "libs/CheatEngine.SDK.Engine/Scanning/A.cs"); + + PwshResult run = await PwshScript.RunFileAsync(EntryScript, ["-ChangedFilesPath", changed], + PullRequestEnvironment("Added CodeQL.")); + + Assert.True(run.ExitCode == 1, run.Transcript); + List annotations = Annotations(run.StandardOutput); + Assert.Equal(3, annotations.Count); + Assert.Contains(annotations, static line => line.StartsWith(ErrorAnnotation + "TitleNoTrailingPeriod: ", StringComparison.Ordinal)); + Assert.Contains(annotations, static line => line.StartsWith(ErrorAnnotation + "TitleImperative: ", StringComparison.Ordinal)); + Assert.Contains(annotations, static line => line.StartsWith(ErrorAnnotation + "ChangelogEntry: ", StringComparison.Ordinal) + && line.Contains("libs/CheatEngine.SDK.Engine/Scanning/A.cs", StringComparison.Ordinal)); + } + + [Fact] + public async Task Entry_script_writes_a_rule_table_to_the_step_summary() + { + using TemporaryDirectory directory = new(); + string changed = await WriteChangedFilesAsync(directory, "libs/X/A.cs", "CHANGELOG.md"); + string summary = directory.File("summary.md"); + Dictionary environment = PullRequestEnvironment("Add CodeQL analysis"); + environment["GITHUB_STEP_SUMMARY"] = summary; + + PwshResult run = await PwshScript.RunFileAsync(EntryScript, ["-ChangedFilesPath", changed], environment); + + Assert.True(run.ExitCode == 0, run.Transcript); + Assert.Empty(Annotations(run.StandardOutput)); + string[] lines = await File.ReadAllLinesAsync(summary, TestContext.Current.CancellationToken); + Assert.Contains("| Rule | Result | Detail |", lines, StringComparer.Ordinal); + foreach (string rule in PullRequestPolicyCases.Rules) + { + Assert.Contains(lines, line => line.StartsWith($"| {rule} | Passed | ", StringComparison.Ordinal)); + } + } + + [Fact] + public async Task Entry_script_exempts_dependabot_and_never_prints_the_description() + { + using TemporaryDirectory directory = new(); + string changed = await WriteChangedFilesAsync(directory, "libs/CheatEngine.SDK.Lua/packages.lock.json", "libs/X/A.cs"); + const string Sentinel = "DESCRIPTION-SENTINEL-7f3a"; + Dictionary environment = PullRequestEnvironment("bump stuff.", "dependabot[bot]"); + environment["PR_BODY"] = "Release notes " + Sentinel; + + PwshResult run = await PwshScript.RunFileAsync(EntryScript, ["-ChangedFilesPath", changed], environment); + + Assert.True(run.ExitCode == 0, run.Transcript); + Assert.Contains("Dependabot pull request: title and changelog rules exempt.", run.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain(Sentinel, run.StandardOutput + run.StandardError, StringComparison.Ordinal); + } + + [Fact] + public async Task Entry_script_refuses_commit_ids_that_are_not_full_hashes() + { + Dictionary environment = PullRequestEnvironment("Add CodeQL analysis"); + environment["BASE_SHA"] = "main"; + environment["HEAD_SHA"] = "HEAD"; + + PwshResult run = await PwshScript.RunFileAsync(EntryScript, [], environment); + + Assert.NotEqual(0, run.ExitCode); + Assert.Contains("must be full commit ids", run.StandardOutput + run.StandardError, StringComparison.Ordinal); + } + + private static Dictionary PullRequestEnvironment(string title, string author = "contributor") + { + return new Dictionary(StringComparer.Ordinal) + { + ["PR_TITLE"] = title, + ["PR_BODY"] = "", + ["PR_AUTHOR"] = author + }; + } + + private static async Task WriteChangedFilesAsync(TemporaryDirectory directory, params string[] paths) + { + string path = directory.File("changed.txt"); + await File.WriteAllLinesAsync(path, paths, TestContext.Current.CancellationToken); + return path; + } + + private static List Annotations(string output) + { + List annotations = []; + foreach (string line in output.ReplaceLineEndings("\n").Split('\n')) + { + if (line.StartsWith(ErrorAnnotation, StringComparison.Ordinal)) + { + annotations.Add(line); + } + } + + return annotations; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyWorkflowTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyWorkflowTests.cs new file mode 100644 index 00000000..e8575990 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PullRequestPolicyWorkflowTests.cs @@ -0,0 +1,132 @@ +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// .github/workflows/pr-policy.yml, the second required check (shared-contracts §1.2, §1.3, §1.12): it runs on +/// every title edit, cannot be skipped by a filter or a condition, and receives pull-request text only through the +/// environment (no template injection). +/// +public sealed partial class PullRequestPolicyWorkflowTests +{ + private const string WorkflowPath = ".github/workflows/pr-policy.yml"; + private const string ModulePath = "eng/ci/PullRequestPolicy.psm1"; + + [Fact] + public void Pr_policy_triggers_on_edited_and_has_no_path_filter() + { + YamlDocument workflow = YamlDocument.Load(WorkflowPath); + + Assert.Equal(["pull_request"], workflow.Triggers); + YamlNode? pullRequest = workflow.Trigger("pull_request"); + Assert.Equal(["opened", "edited", "synchronize", "reopened", "ready_for_review"], + YamlDocument.Scalars(pullRequest, "types")); + foreach (string filter in (string[]) ["paths", "paths-ignore", "branches", "branches-ignore"]) + { + Assert.True(YamlDocument.Child(pullRequest, filter) is null, + $"{WorkflowPath} must not filter on '{filter}': a filtered required check stays pending forever."); + } + } + + [Fact] + public void Pr_policy_job_is_named_PR_policy_and_runs_on_ubuntu_24_04() + { + YamlDocument workflow = YamlDocument.Load(WorkflowPath); + + KeyValuePair job = Assert.Single(workflow.Jobs); + Assert.Equal("policy", job.Key); + Assert.Equal("PR policy", YamlDocument.Scalar(job.Value, "name")); + Assert.Equal("ubuntu-24.04", YamlDocument.Scalar(job.Value, "runs-on")); + Assert.NotNull(YamlDocument.Scalar(job.Value, "timeout-minutes")); + // A skipped job reports success to a required check, so the job has no condition at all (drafts included). + Assert.Null(YamlDocument.Child(job.Value, "if")); + Assert.Equal(new Dictionary(StringComparer.Ordinal) { ["contents"] = "read" }, + YamlDocument.Permissions(workflow.Root)); + Assert.Null(YamlDocument.Permissions(job.Value)); + + YamlMappingNode checkout = Assert.Single(YamlDocument.Steps(job.Value), + static step => YamlDocument.UsesAction(step, "actions/checkout")); + Assert.Equal("0", YamlDocument.Scalar(YamlDocument.Child(checkout, "with"), "fetch-depth")); + Assert.Equal("false", YamlDocument.Scalar(YamlDocument.Child(checkout, "with"), "persist-credentials")); + } + + [Fact] + public void Pull_request_title_body_and_author_reach_the_script_only_through_env() + { + YamlDocument workflow = YamlDocument.Load(WorkflowPath); + YamlMappingNode step = Assert.Single(YamlDocument.Steps(workflow.Job("policy")), + static step => YamlDocument.Scalar(step, "run") is not null); + + // The script's exit code fails the step explicitly (the repository rule for every native command in a workflow). + string[] script = (YamlDocument.Scalar(step, "run") ?? "").ReplaceLineEndings("\n").Trim().Split('\n'); + Assert.Equal("./eng/ci/Test-PullRequestPolicy.ps1", script[0]); + Assert.StartsWith("if ($LASTEXITCODE -ne 0)", script[1], StringComparison.Ordinal); + YamlNode? env = YamlDocument.Child(step, "env"); + Assert.Equal("${{ github.event.pull_request.title }}", YamlDocument.Scalar(env, "PR_TITLE")); + Assert.Equal("${{ github.event.pull_request.body }}", YamlDocument.Scalar(env, "PR_BODY")); + Assert.Equal("${{ github.event.pull_request.user.login }}", YamlDocument.Scalar(env, "PR_AUTHOR")); + Assert.Equal("${{ github.event.pull_request.base.sha }}", YamlDocument.Scalar(env, "BASE_SHA")); + Assert.Equal("${{ github.event.pull_request.head.sha }}", YamlDocument.Scalar(env, "HEAD_SHA")); + + // No workflow of the repository expands attacker-controlled pull-request text inside a script. + List offenders = []; + foreach (string path in GovernanceWorkflows.AllWorkflowPaths()) + { + YamlDocument document = YamlDocument.Load(path); + foreach (KeyValuePair job in document.Jobs) + { + foreach (YamlMappingNode jobStep in YamlDocument.Steps(job.Value)) + { + string? run = YamlDocument.Scalar(jobStep, "run"); + if (run is not null && UntrustedExpression().IsMatch(run)) + { + offenders.Add($"{path} job {job.Key} step '{YamlDocument.Scalar(jobStep, "name")}'"); + } + } + } + } + + Assert.True(offenders.Count == 0, + $"Pass pull-request text through env: instead of expanding it in run: (template injection): {string.Join("; ", offenders)}"); + } + + [Fact] + public void Changelog_path_pattern_matches_the_shared_contract() + { + string module = RepositoryFile.ReadText(ModulePath); + + Assert.Equal("^(libs|src|analyzers|source-generators|native)/", ModuleConstant(module, "ChangelogPathPattern")); + Assert.Equal("(^|/)packages\\.lock\\.json$", ModuleConstant(module, "ChangelogExcludedPattern")); + Assert.Equal("CHANGELOG.md", ModuleConstant(module, "ChangelogFile")); + Assert.Equal("", ModuleConstant(module, "ChangelogWaiverPattern")); + Assert.Equal("dependabot[bot]", ModuleConstant(module, "DependabotLogin")); + Assert.Equal("^\\w+(\\([^)]*\\))?!?:\\s", ModuleConstant(module, "ConventionalPrefixPattern")); + } + + [Fact] + public void Every_consumer_visible_root_of_the_changelog_rule_exists() + { + // A renamed root would silently switch the CHANGELOG rule off for it. + foreach (string root in (string[]) ["libs", "src", "analyzers", "source-generators", "native"]) + { + Assert.True(RepositoryFile.ExistsWithExactCase(root, out bool isDirectory) && isDirectory, + $"The CHANGELOG rule of {ModulePath} names '{root}/', which is not a folder of the repository."); + } + + Assert.True(RepositoryFile.ExistsWithExactCase("CHANGELOG.md", out bool changelogIsDirectory) && !changelogIsDirectory); + } + + private static string ModuleConstant(string module, string name) + { + Match match = Regex.Match(module, "^\\$" + name + " = '(?[^']*)'\\r?$", + RegexOptions.Multiline | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)); + Assert.True(match.Success, $"{ModulePath} must declare ${name} = '...' in its constants block."); + return match.Groups["value"].Value; + } + + [GeneratedRegex(@"\$\{\{[^}]*\b(github\.event\.(pull_request|issue|comment|review|head_commit|commits)\b|github\.head_ref\b)", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] + private static partial Regex UntrustedExpression(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshResult.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshResult.cs new file mode 100644 index 00000000..036ae325 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshResult.cs @@ -0,0 +1,8 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// The outcome of one pwsh process. +internal sealed record PwshResult(int ExitCode, string StandardOutput, string StandardError) +{ + /// Both streams, for assertion messages. + public string Transcript => $"exit code {ExitCode}{Environment.NewLine}--- stdout ---{Environment.NewLine}{StandardOutput}{Environment.NewLine}--- stderr ---{Environment.NewLine}{StandardError}"; +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshScript.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshScript.cs new file mode 100644 index 00000000..424abce7 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/PwshScript.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using System.Text; + +using CheatEngine.SDK.Repository.Tests.Infrastructure; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// Runs the repository's PowerShell policy code in a separate pwsh process: -NoProfile, +/// -NonInteractive, the repository root as working directory, a 120 s limit and the test cancellation token. +/// The GitHub Actions file commands (GITHUB_STEP_SUMMARY, GITHUB_OUTPUT, ...) and the pull-request +/// variables of the scripts are removed from the child environment, so a run inside CI never writes into the job +/// summary or reads the real pull request. A missing pwsh fails the test: it is never skipped +/// (--fail-skips on). +/// +internal static class PwshScript +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(120); + + /// Variables that must come from the test, never from the process that runs the tests. + private static readonly string[] s_isolatedVariables = + [ + "GITHUB_STEP_SUMMARY", "GITHUB_OUTPUT", "GITHUB_ENV", "GITHUB_PATH", "GITHUB_STATE", "GITHUB_ACTIONS", "CI", + "PR_TITLE", "PR_BODY", "PR_AUTHOR", "BASE_SHA", "HEAD_SHA", "GH_TOKEN", "GITHUB_TOKEN" + ]; + + /// Runs from a temporary .ps1 file. + public static async Task RunTextAsync(string scriptText, + IReadOnlyDictionary? environment = null) + { + using TemporaryDirectory directory = new(); + string scriptPath = Path.Combine(directory.Path, "script.ps1"); + string preamble = "Set-StrictMode -Version Latest" + Environment.NewLine + + "$ErrorActionPreference = 'Stop'" + Environment.NewLine; + await File.WriteAllTextAsync(scriptPath, preamble + scriptText, new UTF8Encoding(false), + TestContext.Current.CancellationToken); + return await RunFileAsync(scriptPath, [], environment); + } + + /// Runs a script file with arguments; a repository-relative path is resolved against the root. + public static async Task RunFileAsync(string scriptPath, IReadOnlyList arguments, + IReadOnlyDictionary? environment = null) + { + string fullPath = Path.IsPathRooted(scriptPath) ? scriptPath : RepositoryFile.FullPath(scriptPath); + ProcessStartInfo startInfo = new() + { + FileName = FindPwsh(), + WorkingDirectory = RepositoryRoot.Path, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true + }; + foreach (string argument in (string[]) ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", fullPath]) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (string name in s_isolatedVariables) + { + startInfo.Environment.Remove(name); + } + + if (environment is not null) + { + foreach (KeyValuePair variable in environment) + { + startInfo.Environment[variable.Key] = variable.Value; + } + } + + using CancellationTokenSource timeout = + CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(s_timeout); + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"'{startInfo.FileName}' did not start."); + process.StandardInput.Close(); + Task standardOutput = process.StandardOutput.ReadToEndAsync(timeout.Token); + Task standardError = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); + Assert.Fail($"pwsh did not finish '{RepositoryRoot.ToRelative(fullPath)}' within {s_timeout.TotalSeconds} s."); + } + + return new PwshResult(process.ExitCode, await standardOutput, await standardError); + } + + /// A single-quoted PowerShell string literal. + public static string Quote(string value) + { + return "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + } + + private static string FindPwsh() + { + string fileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh"; + string[] directories = (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (string directory in directories) + { + string candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + { + return candidate; + } + } + + Assert.Fail( + "PowerShell 7 (pwsh) is not on PATH. The governance tests run the repository's PowerShell policy code: install it " + + "(winget install Microsoft.PowerShell, or https://learn.microsoft.com/powershell/scripting/install/installing-powershell) and rerun."); + return fileName; + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/TemporaryDirectory.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/TemporaryDirectory.cs new file mode 100644 index 00000000..be241ad8 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/TemporaryDirectory.cs @@ -0,0 +1,39 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// A uniquely named folder under the temporary directory, deleted on dispose. +internal sealed class TemporaryDirectory : IDisposable +{ + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "cesdk-governance-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + /// The absolute path of the folder. + public string Path + { + get; + } + + /// The absolute path of a file inside the folder. + public string File(string name) + { + return System.IO.Path.Combine(Path, name); + } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + // A virus scanner or a just-exited child can hold a file for a moment; the folder lives under TEMP. + } + catch (UnauthorizedAccessException) + { + // Same as above. + } + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index a9e57516..279af137 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -9,7 +9,9 @@ qualification-matrix and catalogue contracts added by the audit remediation work Several of these rules used to live in scripts that CI stopped running, so they silently rotted (dead `documentations/` links, orphaned validators). Repository rules are enforced by C# tests instead, and they stay fast: -this project only reads committed files. It never builds, packs, restores or starts a process. +this project only reads committed files. It never builds, packs or restores. The one exception is `Governance/`: it +starts `pwsh` to run the repository's PowerShell policy code (the pull request policy module and entry script, the +health-check rules, the repository-settings plan) against test vectors, offline. ## How it works @@ -158,6 +160,20 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow `Roslyn_ignores_cover_every_package_pinned_to_the_roslyn_floor`, `Dotnet_sdk_ecosystem_ignores_major_updates`, `Github_actions_updates_cover_the_composite_action_directories`, `No_ecosystem_sets_a_commit_message_prefix`, `Specific_nuget_groups_come_before_the_catch_all_group`). +- The `PR policy` required check evaluates the title (at most 72 characters, no trailing period, no type or area prefix, + uppercase start, imperative first word) and the CHANGELOG entry for `libs/`, `src/`, `analyzers/`, `source-generators/` + and `native/` changes (lock files excluded, waiver marker, Dependabot exempt), against vectors that include real pull + request titles; the entry script annotates each failed rule, writes the summary table and never prints the description + (`PullRequestPolicyScriptTests`: `Policy_verdict_matches_the_expected_rules`, `Every_rule_is_exercised_by_a_failing_vector`, + `Dependabot_authored_pull_requests_are_exempt_from_every_rule`, `Changelog_failure_names_the_paths_and_both_remedies`, + `Entry_script_exits_non_zero_and_annotates_each_failed_rule`, `Entry_script_writes_a_rule_table_to_the_step_summary`, + `Entry_script_exempts_dependabot_and_never_prints_the_description`, + `Entry_script_refuses_commit_ids_that_are_not_full_hashes`). +- `pr-policy.yml` runs on every title edit without path filter or condition, as the job `PR policy` on `ubuntu-24.04`, + and pull-request text reaches scripts only through `env:` in every workflow (`PullRequestPolicyWorkflowTests`: + `Pr_policy_triggers_on_edited_and_has_no_path_filter`, `Pr_policy_job_is_named_PR_policy_and_runs_on_ubuntu_24_04`, + `Pull_request_title_body_and_author_reach_the_script_only_through_env`, + `Changelog_path_pattern_matches_the_shared_contract`, `Every_consumer_visible_root_of_the_changelog_rule_exists`). ## Run the tests From 6a295bbe951bbf80c8335b3daa458592d1e58e3e Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 03:22:18 +0200 Subject: [PATCH 065/199] Add a security policy, code owners and compatibility issue forms The repository had no SECURITY.md, no CODEOWNERS and no issue form since the planning forms were retired, and the community profile stood at 62%. Checkpoint F of the audit (ch.22) exits only when "a compatibility issue can be tied to a precise tuple" (register A22-43, PR-CQ-36). - SECURITY.md: supported versions (1.0.x fixed, 2.0 prereleases on main only, CESDK 0.x unsupported), private vulnerability reporting only, the tuple a report needs (package version and lock-file contentHash, bridge hash, CE build and executable hash, load profile, runtime configuration hash), scope, a best-effort response target (7 days to acknowledge, 14 to triage; single maintainer), how to verify a release (attested GitHub asset versus the nuget.org re-signed file), and why two binaries are committed (the CE 7.7 Lua fixture and the bridge, which CI rebuilds and checks against its source fingerprint). - CODE_OF_CONDUCT.md: Contributor Covenant 2.1, reports through GitHub private reporting, no personal e-mail address (orchestrator decision CI-BOTH-7, same text as CheatEngine.Client). - .github/CODEOWNERS: informational, @AriusII only; code-owner review stays optional because a required review would block every maintainer and Dependabot pull request. - Issue forms: blank issues off, contact links to private reporting, Discussions and upstream Cheat Engine. The compatibility form requires the full tuple of audit ch.21 (package version and contentHash read from packages.lock.json, bridge SHA-256, CE build, executable and lua53-64.dll hashes, ce.runtimeconfig.json hash and origin, load profile, target architecture, build options, OS, .NET) and what was actually run, from C0 to C4 (ch.20: a package test is not a host result). It names the hostfxr profile id without calling it supported and never asks users to alter Cheat Engine's runtime configuration. GovernanceDocumentTests pin all of this, including exact-case CODEOWNERS paths, the real SHA-256 of the committed Lua fixture, unique form ids, links to main that resolve, and no local path in a form. --- .github/CODEOWNERS | 16 + .github/ISSUE_TEMPLATE/bug_report.yml | 62 +++ .github/ISSUE_TEMPLATE/compatibility.yml | 218 +++++++++++ .github/ISSUE_TEMPLATE/config.yml | 12 + CODE_OF_CONDUCT.md | 108 +++++ CheatEngine.SDK.slnx | 8 + SECURITY.md | 92 +++++ .../Governance/GovernanceDocumentTests.cs | 370 ++++++++++++++++++ .../README.md | 16 + 9 files changed, 902 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/compatibility.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SECURITY.md create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceDocumentTests.cs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..1416134e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,16 @@ +# Informational only: the "Protect main" ruleset does not require a code-owner review (single active maintainer; a +# required review would block every maintainer and Dependabot pull request). GitHub still requests a review from the +# owners below. Owners must have write access; confirm co-owners with the maintainer before adding them. +# Syntax: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# The last matching pattern wins, so the catch-all comes first. +* @AriusII + +# Native authority: the protection bridge, the ABI layouts and the build assets consumers import. +/native/ @AriusII +/libs/CheatEngine.SDK.Abi/ @AriusII +/src/CheatEngine.SDK/build/ @AriusII + +# Build, release and governance: workflows, the PR policy code and repository settings. +/.github/ @AriusII +/eng/ @AriusII +/SECURITY.md @AriusII diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..e6c8f69f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,62 @@ +# Schema: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema +name: Bug report +description: An SDK API, analyzer, generator or build asset behaves differently from its documentation. +title: "[Bug] " +labels: [ bug ] +body: + - type: markdown + attributes: + value: | + Use this form for a defect in the SDK itself. When a plugin does not load, or works with one Cheat Engine, + runtime or package combination and not another, use the **Compatibility report** form instead: it records the + exact tuple the problem depends on. Security problems go to private reporting (see SECURITY.md). + + - type: input + id: sdk-version + attributes: + label: CheatEngine.SDK version + description: The `resolved` version of `CheatEngine.SDK` in the plugin's `packages.lock.json`, or the commit when you build from source. + placeholder: "1.0.0" + validations: + required: true + + - type: input + id: ce-version + attributes: + label: Cheat Engine build + description: The exact file version, or `not involved` for an analyzer, generator or build problem. + placeholder: "7.7.0.10621" + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: A minimal plugin or code snippet and the actions that trigger the problem. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Observed result + description: What happened instead, with the exact error text or diagnostic id. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs (optional) + description: Relevant, redacted build output or log lines. Remove user names and private paths first. + render: text + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/compatibility.yml b/.github/ISSUE_TEMPLATE/compatibility.yml new file mode 100644 index 00000000..7e10a126 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility.yml @@ -0,0 +1,218 @@ +# A compatibility problem is only actionable when it names the exact tuple that was run (audit ch.21 "the tuple to +# qualify", ch.22 Checkpoint F exit): package and content hash, bridge, Cheat Engine build and executable, the Lua DLL +# actually bound, runtime configuration, load profile, build options and the level of evidence. +# Schema: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema +name: Compatibility report +description: A plugin built with CheatEngine.SDK does not load or behave as expected with a specific Cheat Engine, runtime or package combination. +title: "[Compatibility] " +labels: [ compatibility ] +body: + - type: markdown + attributes: + value: | + A compatibility report is tied to one exact combination of package, bridge, Cheat Engine build and runtime. + Fill in every hash from the files that were actually used; a version name alone does not identify a build. + The profiles the project describes, and their qualification status, are listed in the + [support profile](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/docs/qualification/support-profile.md). + + Collect a SHA-256 with PowerShell: + + ```powershell + Get-FileHash -Algorithm SHA256 "$env:ProgramFiles\Cheat Engine\cheatengine-x86_64.exe" + Get-FileHash -Algorithm SHA256 "$env:ProgramFiles\Cheat Engine\lua53-64.dll" + Get-FileHash -Algorithm SHA256 "$env:ProgramFiles\Cheat Engine\ce.runtimeconfig.json" + Get-FileHash -Algorithm SHA256 .\bin\Release\net10.0\cheatengine-sdk-lua-bridge.dll + ``` + + Read the package identity from the plugin's `packages.lock.json` (never recompute it): + + ```powershell + (Get-Content packages.lock.json -Raw | ConvertFrom-Json).dependencies.'net10.0'.'CheatEngine.SDK' | Select-Object resolved, contentHash + ``` + + Remove user names and private paths before posting. Never attach Cheat Engine or target binaries. + Security problems go to private reporting instead (see SECURITY.md). + + - type: input + id: sdk-version + attributes: + label: CheatEngine.SDK version + description: The `resolved` version of `CheatEngine.SDK` in the plugin's `packages.lock.json`. + placeholder: "1.0.0" + validations: + required: true + + - type: input + id: sdk-content-hash + attributes: + label: CheatEngine.SDK contentHash (from packages.lock.json) + description: The `contentHash` value of `CheatEngine.SDK` exactly as the lock file records it (base64 SHA-512). Copy it; do not recompute it from a downloaded file. + placeholder: "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + validations: + required: true + + - type: input + id: bridge-sha256 + attributes: + label: SHA-256 of cheatengine-sdk-lua-bridge.dll + description: The bridge next to the plugin in the folder Cheat Engine loads it from. + validations: + required: true + + - type: input + id: bridge-fingerprint + attributes: + label: Bridge source fingerprint (optional) + description: The `:` fingerprint, if the SDK printed an identification line in the Cheat Engine log. + validations: + required: false + + - type: input + id: client-version + attributes: + label: CheatEngine.Client version (optional) + description: Only when the plugin uses CheatEngine.Client. + validations: + required: false + + - type: input + id: ce-version + attributes: + label: Cheat Engine build + description: The exact file version of the executable you started (Properties, Details, File version). + placeholder: "7.7.0.10621" + validations: + required: true + + - type: input + id: ce-exe-sha256 + attributes: + label: SHA-256 of the Cheat Engine executable + description: Usually `cheatengine-x86_64.exe`. The `-SSE4-AVX2` variant and the `Cheat Engine.exe` launcher are different files; name the one you started. + validations: + required: true + + - type: input + id: lua-dll-sha256 + attributes: + label: SHA-256 of lua53-64.dll + description: The `lua53-64.dll` in the Cheat Engine folder, which is the Lua a plugin actually binds. + validations: + required: true + + - type: dropdown + id: load-profile + attributes: + label: Load profile + description: How Cheat Engine loaded the plugin. + options: + - Managed plugin through hostfxr (profile ce-7.7.0.10621-x64-managed-hostfxr) + - Historical CLR loader + - NativeAOT plugin (not supported) + - Other or unknown + validations: + required: true + + - type: input + id: runtimeconfig-sha256 + attributes: + label: SHA-256 of ce.runtimeconfig.json + description: The file in the Cheat Engine folder, as found, for the managed route. Write `none` when the file does not exist. Report it exactly as it is; this form never asks you to alter Cheat Engine's runtime configuration. + validations: + required: true + + - type: dropdown + id: runtimeconfig-origin + attributes: + label: Origin of ce.runtimeconfig.json + description: Whether the file is the one the Cheat Engine installer wrote. A locally altered file affects every managed plugin, so it changes the profile under test. + options: + - Installer file, never altered + - Altered on this machine + - Unknown + - No such file + validations: + required: true + + - type: dropdown + id: target-architecture + attributes: + label: Target process architecture + options: + - x64 target + - x86 target (32-bit process) + - No target process opened + - Other + validations: + required: true + + - type: dropdown + id: evidence-level + attributes: + label: What was actually run + description: Choose the highest level you ran yourself. Package or fixture tests are not a Cheat Engine host result, and a build that succeeds is not a plugin that loads. + options: + - Plugin loaded in Cheat Engine (C3) + - Two plugins or a target switch (C4) + - Package or fixture tests only (C1/C2) + - Build or analyzer output only (C0) + validations: + required: true + + - type: textarea + id: build-options + attributes: + label: Build options + description: The relevant project properties, for example `TargetFramework`, `PlatformTarget`, `PublishAot`, `SelfContained`, `EnableDynamicLoading`, and how the plugin was deployed. + render: xml + validations: + required: true + + - type: input + id: os + attributes: + label: Windows version + placeholder: "Windows 11 24H2 (26100.1)" + validations: + required: true + + - type: textarea + id: dotnet + attributes: + label: .NET host and runtimes + description: The "Host" and ".NET runtimes installed" sections of `dotnet --info`. + render: text + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: From a clean start of Cheat Engine, including the target and the plugin actions. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Observed result + description: What happened instead, with the exact error text. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs (optional) + description: Relevant, redacted log lines. Remove user names and private paths first. + render: text + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..21cf20e9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,12 @@ +# Issue forms: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/CheatEngineNet/CheatEngine.SDK/security/advisories/new + about: Report privately through GitHub private vulnerability reporting, never in a public issue (see SECURITY.md). + - name: Questions and ideas + url: https://github.com/CheatEngineNet/CheatEngine.SDK/discussions + about: Ask how to use the SDK or propose an idea in GitHub Discussions. + - name: Problems in Cheat Engine itself + url: https://github.com/cheat-engine/cheat-engine/issues + about: Behaviour of Cheat Engine that also happens without a CheatEngine.SDK plugin belongs upstream. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..c4a994b5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,108 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for +everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity +and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, +color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take +appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, +issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing +the community in public spaces. Examples of representing our community include using an official e-mail address, posting +via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible +for enforcement through GitHub private reporting: open a private report at + and start its title with "Code of +Conduct"; only you and the maintainers can read it. All complaints will be reviewed and investigated promptly and +fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem +in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the +community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation +and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including +unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding +interactions in community spaces as well as external channels like social media. Violating these terms may lead to a +temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified +period of time. No public or private interaction with the people involved, including unsolicited interaction with those +enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate +behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 80233296..aff63e4e 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -5,6 +5,7 @@ + @@ -13,8 +14,10 @@ + + @@ -22,6 +25,11 @@ + + + + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..287b0891 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,92 @@ +# Security policy + +CheatEngine.SDK is a Windows x64 SDK for Cheat Engine 7.7 plugins: a NuGet package with managed libraries, Roslyn +analyzers and generators, MSBuild assets and a native protection bridge. This page says which versions receive security +fixes, how to report a vulnerability privately, and how to check that a package came from this repository. + +## Supported versions + +| Version | Package | Security fixes | +|---|---|---| +| 1.0.x, the latest published release line | `CheatEngine.SDK` on nuget.org | Yes, as a new 1.0.x patch release | +| 2.0.0 prereleases built from `main` | CI artifacts and prerelease packages | On `main` only; a prerelease is never patched in place | +| 0.1.0 to 0.2.1 | the former `CESDK` package ID | No; move to `CheatEngine.SDK` (see [CHANGELOG.md](CHANGELOG.md)) | + +When a new release line ships, the previous line stops receiving fixes; the table is updated in the same pull request. + +## Reporting a vulnerability + +Report vulnerabilities privately through GitHub private vulnerability reporting: +. + +Never report a vulnerability in a public issue, discussion or pull request. + +A report can be triaged fastest when it identifies the exact combination that was run: + +- the `CheatEngine.SDK` version and its `contentHash`, both read from the plugin's `packages.lock.json` (never + recomputed); +- the SHA-256 of `cheatengine-sdk-lua-bridge.dll` in the plugin output folder; +- the Cheat Engine build (for example `7.7.0.10621`) and the SHA-256 of the executable you started, usually + `cheatengine-x86_64.exe`; +- the plugin load profile (managed plugin through hostfxr, historical CLR loader, or Native AOT) and the SHA-256 of + `ce.runtimeconfig.json` when the managed route is used; +- the Windows version and the output of `dotnet --info`; +- the impact (what an attacker controls and what they gain) and a minimal reproduction. + +Hashes can be read with PowerShell, for example +`Get-FileHash -Algorithm SHA256 "$env:ProgramFiles\Cheat Engine\cheatengine-x86_64.exe"`. Remove user names and private +paths from logs. Never attach Cheat Engine binaries, target binaries or raw debugger dumps; describe them by name, +version and hash instead. + +## Scope + +In scope: + +- the `CheatEngine.SDK` package: its libraries, analyzers, source generators, MSBuild build assets and the bundled + native protection bridge `cheatengine-sdk-lua-bridge.dll`; +- the bridge sources under `native/cheatengine-sdk-lua-bridge`; +- this repository's build, test and release workflows, including the provenance of published packages. + +Out of scope: + +- vulnerabilities in Cheat Engine itself: report them upstream at ; +- vulnerabilities in CheatEngine.Client: report them at + ; +- use of Cheat Engine or of a plugin against software or processes you are not authorized to inspect or modify. + +## Response + +- An acknowledgement within 7 days and a triage decision within 14 days, on a best-effort basis: the project has a + single active maintainer. +- Fixes are coordinated through a GitHub Security Advisory on this repository, which credits the reporter unless they + ask otherwise. +- A fix ships as a new package version with a `Security` entry in [CHANGELOG.md](CHANGELOG.md). The advisory is + published once the fixed package is available on nuget.org. + +## Verifying releases + +Packages are built, tested, packed and published by the `release.yml` workflow of this repository from a version tag, +through NuGet trusted publishing (no long-lived API key), and the published package gets a GitHub build provenance +attestation. [RELEASING.md](RELEASING.md) describes the process. + +A package therefore has two files with different hashes: + +- the package attached to the GitHub release, which is the attested file: + `gh attestation verify CheatEngine.SDK..nupkg --repo CheatEngineNet/CheatEngine.SDK`; +- the package served by nuget.org, which nuget.org re-signs with its repository signature, so its SHA-256 differs from + the GitHub asset: `dotnet nuget verify --all CheatEngine.SDK..nupkg`. + +The `contentHash` that NuGet writes into a consumer's `packages.lock.json` is computed without the repository +signature, so it identifies the package content independently of where it was downloaded. + +## Binary files in this repository + +Two binaries are committed on purpose; everything else is built from source. + +- `native/cheat-engine/lua53-64.dll` is the unmodified 64-bit Lua library of a Cheat Engine 7.7 installation, SHA-256 + `C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2` ([its README](native/cheat-engine/README.md)). + Tests and benchmarks bind it so that they exercise the Lua a plugin binds in production; it is never packed. +- `native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll` is the native protection + bridge that the package ships. CI rebuilds it from its C source and `xmake.lua` on every run, checks that two builds + are identical, and checks the committed DLL against the source fingerprint it exports. The weekly scheduled health + workflow also rebuilds the bridge of the latest release tag and compares it with the released DLL. diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceDocumentTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceDocumentTests.cs new file mode 100644 index 00000000..6d6dc6a1 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceDocumentTests.cs @@ -0,0 +1,370 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.Repository.Tests.Documentation; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// The community and security documents (audit register PR-CQ-36, A22-43): SECURITY.md, +/// CODE_OF_CONDUCT.md, the informational .github/CODEOWNERS and the issue forms, whose compatibility +/// form captures the complete support tuple (audit ch.21 "the tuple to qualify", ch.22 Checkpoint F exit) and never +/// tells users to alter Cheat Engine's runtime configuration (ch.21 "CE runtime"). +/// +public sealed partial class GovernanceDocumentTests +{ + private const string SecurityPolicy = "SECURITY.md"; + private const string CodeOfConduct = "CODE_OF_CONDUCT.md"; + private const string CodeOwners = ".github/CODEOWNERS"; + private const string IssueTemplates = ".github/ISSUE_TEMPLATE"; + private const string CompatibilityForm = ".github/ISSUE_TEMPLATE/compatibility.yml"; + private const string ChooserConfiguration = ".github/ISSUE_TEMPLATE/config.yml"; + private const string PrivateReportingUrl = "https://github.com/CheatEngineNet/CheatEngine.SDK/security/advisories/new"; + private const string LuaFixture = "native/cheat-engine/lua53-64.dll"; + private const string BridgeBinary = "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"; + + /// Maintainers who may own paths; CODEOWNERS is informational (no required code-owner review). + private static readonly string[] s_knownOwners = ["@AriusII", "@ShadowNineX"]; + + /// The support tuple of the compatibility form: required ids, then optional ids. + private static readonly string[] s_requiredTupleIds = + [ + "sdk-version", "sdk-content-hash", "bridge-sha256", "ce-version", "ce-exe-sha256", "lua-dll-sha256", + "runtimeconfig-sha256", "runtimeconfig-origin", "load-profile", "target-architecture", "evidence-level", + "build-options", "os", "dotnet", "steps", "expected", "actual" + ]; + + private static readonly string[] s_optionalTupleIds = ["bridge-fingerprint", "client-version", "logs"]; + + [Fact] + public void Security_policy_names_private_reporting_scope_response_and_supported_versions() + { + MarkdownDocument policy = MarkdownDocument.Parse(RepositoryFile.ReadText(SecurityPolicy)); + + List sections = []; + foreach (MarkdownHeading heading in policy.Headings) + { + if (heading.Level == 2) + { + sections.Add(heading.Text); + } + } + + Assert.Equal( + ["Supported versions", "Reporting a vulnerability", "Scope", "Response", "Verifying releases", "Binary files in this repository"], + sections); + Assert.Contains(PrivateReportingUrl, policy.Text, StringComparison.Ordinal); + Assert.Contains("Never report a vulnerability in a public issue", policy.Text, StringComparison.Ordinal); + Assert.Contains("within 7 days", policy.Text, StringComparison.Ordinal); + Assert.Contains("https://github.com/cheat-engine/cheat-engine", policy.Text, StringComparison.Ordinal); + // The package identity a report must carry comes from the consumer's lock file (audit ADR-10). + Assert.Contains("`contentHash`", policy.Text, StringComparison.Ordinal); + Assert.Contains("packages.lock.json", policy.Text, StringComparison.Ordinal); + Assert.Contains("[RELEASING.md](RELEASING.md)", policy.Text, StringComparison.Ordinal); + } + + [Fact] + public void Security_policy_describes_the_committed_binaries_with_their_real_hash() + { + string policy = RepositoryFile.ReadText(SecurityPolicy); + foreach (string binary in (string[]) [LuaFixture, BridgeBinary]) + { + Assert.Contains($"`{binary}`", policy, StringComparison.Ordinal); + Assert.True(RepositoryFile.ExistsWithExactCase(binary, out bool isDirectory) && !isDirectory, + $"{SecurityPolicy} describes '{binary}', which is not in the repository."); + } + + string actual = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(RepositoryFile.FullPath(LuaFixture)))); + Assert.Contains($"`{actual}`", policy, StringComparison.Ordinal); + Assert.Contains($"`{actual}`", RepositoryFile.ReadText("native/cheat-engine/README.md"), StringComparison.Ordinal); + } + + [Fact] + public void Code_of_conduct_routes_reports_through_private_reporting_without_an_email_address() + { + string text = RepositoryFile.ReadText(CodeOfConduct); + + Assert.Contains("https://www.contributor-covenant.org/version/2/1/code_of_conduct.html", text, StringComparison.Ordinal); + Assert.Contains(PrivateReportingUrl, text, StringComparison.Ordinal); + Assert.DoesNotMatch(EmailAddress(), text); + } + + [Fact] + public void Codeowners_patterns_point_to_existing_paths_and_known_owners() + { + List<(string Pattern, string[] Owners)> rules = []; + foreach (string rawLine in RepositoryFile.ReadLines(CodeOwners)) + { + string line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + { + continue; + } + + string[] parts = line.Split((char[]?) null, StringSplitOptions.RemoveEmptyEntries); + rules.Add((parts[0], parts[1..])); + } + + Assert.NotEmpty(rules); + Assert.Equal("*", rules[0].Pattern); + foreach ((string pattern, string[] owners) in rules) + { + Assert.NotEmpty(owners); + foreach (string owner in owners) + { + Assert.True(Array.IndexOf(s_knownOwners, owner) >= 0, $"{CodeOwners}: '{owner}' is not a known maintainer."); + } + + // GitHub rejects "!" negation and "[ ]" ranges in CODEOWNERS. + Assert.True(pattern.AsSpan().IndexOfAny("![]") < 0, $"{CodeOwners}: '{pattern}' uses unsupported syntax."); + if (string.Equals(pattern, "*", StringComparison.Ordinal)) + { + continue; + } + + string path = pattern.Trim('/'); + Assert.True(RepositoryFile.ExistsWithExactCase(path, out bool isDirectory), + $"{CodeOwners}: '{pattern}' matches nothing in the repository (exact case)."); + Assert.True(!pattern.EndsWith('/') || isDirectory, $"{CodeOwners}: '{pattern}' names a folder that is a file."); + } + } + + [Fact] + public void Codeowners_exists_only_in_the_github_folder() + { + // GitHub uses the first of .github/, the root and docs/; a second file would be silently ignored. + Assert.True(File.Exists(RepositoryFile.FullPath(CodeOwners))); + Assert.False(File.Exists(RepositoryFile.FullPath("CODEOWNERS"))); + Assert.False(File.Exists(RepositoryFile.FullPath("docs/CODEOWNERS"))); + } + + [Fact] + public void Compatibility_issue_form_requires_the_full_tuple() + { + Dictionary elements = ElementsById(YamlDocument.Load(CompatibilityForm)); + + foreach (string id in s_requiredTupleIds) + { + Assert.True(elements.TryGetValue(id, out YamlMappingNode? element), $"{CompatibilityForm} has no '{id}' field."); + Assert.True(IsRequired(element), $"{CompatibilityForm}: '{id}' must be required."); + } + + foreach (string id in s_optionalTupleIds) + { + Assert.True(elements.TryGetValue(id, out YamlMappingNode? element), $"{CompatibilityForm} has no '{id}' field."); + Assert.False(IsRequired(element), $"{CompatibilityForm}: '{id}' is optional."); + } + + IReadOnlyList levels = YamlDocument.Scalars(YamlDocument.Child(elements["evidence-level"], "attributes"), "options"); + foreach (string level in (string[]) ["(C0)", "(C1/C2)", "(C3)", "(C4)"]) + { + Assert.Contains(levels, option => option.EndsWith(level, StringComparison.Ordinal)); + } + + IReadOnlyList profiles = YamlDocument.Scalars(YamlDocument.Child(elements["load-profile"], "attributes"), "options"); + Assert.Contains(profiles, static option => option.Contains("ce-7.7.0.10621-x64-managed-hostfxr", StringComparison.Ordinal)); + } + + [Fact] + public void Compatibility_form_never_presents_a_profile_as_qualified_or_supported() + { + // The support profile starts NotExecuted: a form option must not claim more (audit ch.20 publication criterion). + foreach (string text in FormStrings(YamlDocument.Load(CompatibilityForm))) + { + foreach (string sentence in Sentences(text)) + { + if (QualificationClaim().IsMatch(sentence) && !DocumentationConventions.NegationPattern.IsMatch(sentence)) + { + Assert.Fail($"{CompatibilityForm} claims support or qualification: '{sentence}'."); + } + } + } + } + + [Fact] + public void Issue_form_element_ids_are_unique_and_valid() + { + List forms = IssueForms(); + Assert.Contains(CompatibilityForm, forms, StringComparer.Ordinal); + Assert.Contains(".github/ISSUE_TEMPLATE/bug_report.yml", forms, StringComparer.Ordinal); + + foreach (string path in forms) + { + YamlDocument form = YamlDocument.Load(path); + foreach (string key in (string[]) ["name", "description"]) + { + Assert.False(string.IsNullOrWhiteSpace(YamlDocument.Scalar(form.Root, key)), $"{path} has no {key}."); + } + + HashSet ids = new(StringComparer.Ordinal); + IReadOnlyList body = YamlDocument.Mappings(form.Root, "body"); + Assert.NotEmpty(body); + foreach (YamlMappingNode element in body) + { + string type = YamlDocument.Scalar(element, "type") ?? ""; + Assert.Contains(type, (string[]) ["markdown", "input", "textarea", "dropdown", "checkboxes"], StringComparer.Ordinal); + string? id = YamlDocument.Scalar(element, "id"); + if (string.Equals(type, "markdown", StringComparison.Ordinal)) + { + continue; + } + + Assert.True(id is not null && ElementId().IsMatch(id), $"{path}: a {type} element has no valid id ('{id}')."); + Assert.True(ids.Add(id), $"{path}: the id '{id}' is used twice."); + YamlNode? attributes = YamlDocument.Child(element, "attributes"); + Assert.False(string.IsNullOrWhiteSpace(YamlDocument.Scalar(attributes, "label")), $"{path}: '{id}' has no label."); + if (string.Equals(type, "dropdown", StringComparison.Ordinal)) + { + IReadOnlyList options = YamlDocument.Scalars(attributes, "options"); + Assert.NotEmpty(options); + Assert.Equal(options.Count, new HashSet(options, StringComparer.Ordinal).Count); + } + } + + // Forms are public: no local path, and every link to this repository resolves on main. + Assert.Empty(MarkdownDocument.Parse(form.Text).FindLocalPaths()); + foreach (Match link in DocumentationConventions.SelfLinkPattern.Matches(form.Text)) + { + Assert.True(DocumentationRules.TryCheckSelfLink(link.Value, out string? problem) is false || problem is null, + $"{path}: {link.Value} does not resolve ({problem})."); + } + } + } + + [Fact] + public void Issue_forms_disable_blank_issues_and_link_private_reporting() + { + YamlDocument chooser = YamlDocument.Load(ChooserConfiguration); + + Assert.Equal("false", YamlDocument.Scalar(chooser.Root, "blank_issues_enabled")); + List urls = []; + foreach (YamlMappingNode link in YamlDocument.Mappings(chooser.Root, "contact_links")) + { + foreach (string key in (string[]) ["name", "url", "about"]) + { + Assert.False(string.IsNullOrWhiteSpace(YamlDocument.Scalar(link, key)), $"{ChooserConfiguration}: a contact link has no {key}."); + } + + string url = YamlDocument.Scalar(link, "url")!; + Assert.StartsWith("https://", url, StringComparison.Ordinal); + urls.Add(url); + } + + Assert.Contains(PrivateReportingUrl, urls, StringComparer.Ordinal); + Assert.Contains("https://github.com/CheatEngineNet/CheatEngine.SDK/discussions", urls, StringComparer.Ordinal); + } + + [Fact] + public void Issue_forms_never_instruct_editing_the_cheat_engine_runtime_configuration() + { + // Audit ch.21: editing a global CE runtime configuration is never a harmless step. A sentence may mention the file + // and an edit only to rule the edit out. + List offenders = []; + foreach (string path in IssueForms()) + { + foreach (string text in FormStrings(YamlDocument.Load(path))) + { + foreach (string sentence in Sentences(text)) + { + if (sentence.Contains("runtimeconfig", StringComparison.OrdinalIgnoreCase) + && EditInstruction().IsMatch(sentence) + && !DocumentationConventions.NegationPattern.IsMatch(sentence)) + { + offenders.Add($"{path}: '{sentence}'"); + } + } + } + } + + Assert.True(offenders.Count == 0, $"Issue forms must never ask users to edit ce.runtimeconfig.json: {string.Join("; ", offenders)}"); + } + + private static List IssueForms() + { + List forms = []; + foreach (string file in Directory.EnumerateFiles(RepositoryFile.FullPath(IssueTemplates), "*.yml")) + { + string relative = IssueTemplates + "/" + Path.GetFileName(file); + if (!string.Equals(relative, ChooserConfiguration, StringComparison.Ordinal)) + { + forms.Add(relative); + } + } + + forms.Sort(StringComparer.Ordinal); + return forms; + } + + private static Dictionary ElementsById(YamlDocument form) + { + Dictionary elements = new(StringComparer.Ordinal); + foreach (YamlMappingNode element in YamlDocument.Mappings(form.Root, "body")) + { + string? id = YamlDocument.Scalar(element, "id"); + if (id is not null) + { + elements[id] = element; + } + } + + return elements; + } + + private static bool IsRequired(YamlMappingNode element) + { + return string.Equals(YamlDocument.Scalar(YamlDocument.Child(element, "validations"), "required"), "true", + StringComparison.Ordinal); + } + + /// Every user-visible string of a form: its description and each element's texts and options. + private static List FormStrings(YamlDocument form) + { + List strings = []; + foreach (string key in (string[]) ["name", "description", "title"]) + { + strings.AddRange(YamlDocument.Scalars(form.Root, key)); + } + + foreach (YamlMappingNode element in YamlDocument.Mappings(form.Root, "body")) + { + YamlNode? attributes = YamlDocument.Child(element, "attributes"); + foreach (string key in (string[]) ["label", "description", "placeholder", "value", "options"]) + { + strings.AddRange(YamlDocument.Scalars(attributes, key)); + } + } + + return strings; + } + + private static IEnumerable Sentences(string text) + { + foreach (string sentence in SentenceBoundary().Split(text)) + { + string trimmed = sentence.Trim(); + if (trimmed.Length != 0) + { + yield return trimmed; + } + } + } + + [GeneratedRegex(@"(?<=[.!?;])\s+|\r?\n", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex SentenceBoundary(); + + [GeneratedRegex(@"\b(edit|modify|change|replace|overwrite)", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex EditInstruction(); + + [GeneratedRegex(@"\b(supported|qualified|compatible)\b", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex QualificationClaim(); + + [GeneratedRegex("^[A-Za-z0-9_-]+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex ElementId(); + + [GeneratedRegex(@"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex EmailAddress(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index 279af137..f4622215 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -174,6 +174,22 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow `Pr_policy_triggers_on_edited_and_has_no_path_filter`, `Pr_policy_job_is_named_PR_policy_and_runs_on_ubuntu_24_04`, `Pull_request_title_body_and_author_reach_the_script_only_through_env`, `Changelog_path_pattern_matches_the_shared_contract`, `Every_consumer_visible_root_of_the_changelog_rule_exists`). +- `SECURITY.md` names private reporting, scope, response targets, supported versions and release verification, and + describes the two committed binaries with their real hash; `CODE_OF_CONDUCT.md` routes reports through private + reporting without an e-mail address; `.github/CODEOWNERS` is the only CODEOWNERS file, starts with `*`, names known + maintainers and existing paths with exact case (`GovernanceDocumentTests`: + `Security_policy_names_private_reporting_scope_response_and_supported_versions`, + `Security_policy_describes_the_committed_binaries_with_their_real_hash`, + `Code_of_conduct_routes_reports_through_private_reporting_without_an_email_address`, + `Codeowners_patterns_point_to_existing_paths_and_known_owners`, `Codeowners_exists_only_in_the_github_folder`). +- The compatibility issue form requires the complete support tuple (package version and lock-file `contentHash`, + bridge hash, Cheat Engine build and executable hash, Lua DLL hash, runtime-configuration hash and origin, load + profile, target architecture, what was actually run from C0 to C4, build options, OS and .NET), never presents a + profile as supported or qualified, and no form tells users to edit `ce.runtimeconfig.json`; blank issues are off and + the chooser links private reporting (`GovernanceDocumentTests`: `Compatibility_issue_form_requires_the_full_tuple`, + `Compatibility_form_never_presents_a_profile_as_qualified_or_supported`, + `Issue_form_element_ids_are_unique_and_valid`, `Issue_forms_disable_blank_issues_and_link_private_reporting`, + `Issue_forms_never_instruct_editing_the_cheat_engine_runtime_configuration`). ## Run the tests From ec0c1d7fbf50a1596cb4bb4451d4d1526b7c9605 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 03:25:07 +0200 Subject: [PATCH 066/199] Add advisory CodeQL analysis for C#, C/C++ and workflows Code scanning default setup is not configured and no workflow analysed the code (audit register PR-CQ-22). CodeQL runs outside CI / Gate, one job per language with literal runner labels: - csharp (windows-2025): locked restore of src/CheatEngine.SDK through the composite action, then a manual traced Release build of the shipped product graph with --no-incremental, --disable-build-servers and -p:UseSharedCompilation=false, because the tracer only sees newly created csc processes and a compiler server hides them. CodeQL injects EmitCompilerGeneratedFiles, so generator output is analysed, which build-mode none would skip. fetch-depth 0 lets MinVer compute the real version. The binary log is uploaded as binlogs-codeql on failure only. - c-cpp (windows-2025, build-mode none): the native bridge and the ABI fixture sources, with the Windows SDK headers visible. - actions (ubuntu-24.04): the workflows and the composite action. Python is not analysed: its sources are gone and an empty language fails. The workflow runs on pull requests (drafts skipped), pushes to main, a weekly schedule and dispatch, so it is validated on the vehicle pull request before merge (PR-SEQ-15). No NuGet, dependency or TRAP cache is enabled on this path. Each job elevates only security-events: write (and actions: read), with the reason commented. Default setup must stay off, because GitHub rejects advanced-setup uploads while it is on. GovernanceWorkflowTests add the repository conventions for every governance workflow (SHA pins with version comments, literal runners, timeouts, read-only top level, commented elevations, no persisted credentials, no pull_request_target or merge_group, no cache, reserved artifact names) and the CodeQL specifics. The traced command was rehearsed locally without CodeQL (Release, 31 s). https://learn.microsoft.com/dotnet/core/tools/dotnet-build https://docs.github.com/en/code-security/reference/code-scanning/codeql/build-options-for-compiled-languages --- .github/workflows/codeql.yml | 147 ++++++++ CheatEngine.SDK.slnx | 1 + .../Governance/GovernanceWorkflowTests.cs | 346 ++++++++++++++++++ .../README.md | 14 + 4 files changed, 508 insertions(+) create mode 100644 .github/workflows/codeql.yml create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..acc99a5a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,147 @@ +# Advisory CodeQL code scanning (audit register PR-CQ-22). Not part of CI / Gate: findings go to the Security tab and +# appear as pull request annotations. +# - C#: a manual, traced Release build of the shipped product graph (src/CheatEngine.SDK builds the six libraries, the +# analyzers and every source generator it packs), so generated code is analysed; build-mode none would skip it. +# - C/C++: the native bridge and the ABI fixture sources, without a build (build-mode none), on Windows so the +# extractor sees the Windows SDK headers. +# - GitHub Actions: the workflows and the composite action. +# The repository's code-scanning default setup must stay OFF: GitHub rejects advanced-setup uploads while it is on. +# No NuGet, dependency or TRAP cache: no cache on any path reachable by codeql (shared-contracts 1.5). +# A fork pull request gets a read-only token, so its SARIF upload can fail; the workflow is advisory and never moves to +# pull_request_target. +name: CodeQL + +on: + push: + branches: [ main ] + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + schedule: + - cron: '17 3 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + csharp: + name: Analyze (csharp) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: windows-2025 + timeout-minutes: 45 + permissions: + contents: read + security-events: write # upload CodeQL SARIF + actions: read # CodeQL reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # MinVer computes the real version, so no version-dependent guard sees a fallback + persist-credentials: false + + - name: Set up .NET and restore the product graph + uses: ./.github/actions/setup-dotnet + with: + restore: src/CheatEngine.SDK/CheatEngine.SDK.csproj + + - name: Initialize CodeQL + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + languages: csharp + build-mode: manual + queries: security-extended + dependency-caching: false + trap-caching: false + + # The compiler must run inside the traced build: no incremental skip, no build server, no compiler server (the + # tracer only sees newly created csc processes). CodeQL injects EmitCompilerGeneratedFiles itself. + # https://learn.microsoft.com/dotnet/core/tools/dotnet-build + # https://docs.github.com/en/code-security/reference/code-scanning/codeql/build-options-for-compiled-languages + - name: Build the shipped product graph + run: | + $ErrorActionPreference = 'Stop' + dotnet build src/CheatEngine.SDK/CheatEngine.SDK.csproj -c Release --no-restore --no-incremental --disable-build-servers -p:UseSharedCompilation=false -bl:artifacts/logs/codeql-csharp.binlog + if ($LASTEXITCODE -ne 0) { + throw "CodeQL traced build failed with exit code $LASTEXITCODE." + } + + - name: Analyze + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + category: /language:csharp + + - name: Upload binary log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binlogs-codeql + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + + cpp: + name: Analyze (c-cpp) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: windows-2025 + timeout-minutes: 20 + permissions: + contents: read + security-events: write # upload CodeQL SARIF + actions: read # CodeQL reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + languages: c-cpp + build-mode: none + queries: security-extended + dependency-caching: false + trap-caching: false + + - name: Analyze + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + category: /language:c-cpp + + actions: + name: Analyze (actions) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + security-events: write # upload CodeQL SARIF + actions: read # CodeQL reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + languages: actions + build-mode: none + queries: security-extended + dependency-caching: false + trap-caching: false + + - name: Analyze + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + category: /language:actions diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index aff63e4e..2b0b7c6e 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -32,6 +32,7 @@ + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs new file mode 100644 index 00000000..173148f5 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs @@ -0,0 +1,346 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// The advisory governance workflows outside CI / Gate (CodeQL, Scorecard, online zizmor, dependency +/// submission, scheduled health) and the PR policy: repository conventions (shared-contracts §1.6, §1.13) and the +/// choices that keep each one safe and useful (audit register PR-CQ-19, -22, -24, -30, -45, -55). +/// +public sealed partial class GovernanceWorkflowTests +{ + private static readonly string[] s_runnerLabels = ["windows-2025", "ubuntu-24.04"]; + + /// Artifact names the governance workflows may upload (requested for shared-contracts §1.9). + private static readonly string[] s_governanceArtifacts = + ["binlogs-codeql", "dependency-snapshot", "health-sdk-canary", "health-bridge-drift", "health-test-repeat"]; + + [Fact] + public void Governance_workflows_pin_every_action_by_full_sha_with_a_version_comment() + { + List offenders = []; + foreach (string path in GovernanceWorkflows.Existing()) + { + string[] lines = RepositoryFile.ReadLines(path); + for (int i = 0; i < lines.Length; i++) + { + Match uses = UsesLine().Match(lines[i]); + if (uses.Success && !uses.Groups["reference"].Value.StartsWith("./", StringComparison.Ordinal) + && !(PinnedReference().IsMatch(uses.Groups["reference"].Value) + && VersionComment().IsMatch(uses.Groups["comment"].Value))) + { + offenders.Add($"{path}:{i + 1}: {lines[i].Trim()}"); + } + } + } + + Assert.True(offenders.Count == 0, + $"Pin every action as owner/repo@<40-hex> # vX.Y.Z (shared-contracts 1.13): {string.Join("; ", offenders)}"); + } + + [Fact] + public void Governance_jobs_use_literal_runner_labels_and_timeouts() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + string? runner = YamlDocument.Scalar(job.Value, "runs-on"); + Assert.True(runner is not null && Array.IndexOf(s_runnerLabels, runner) >= 0, + $"{path} job {job.Key}: runs-on '{runner}' must be a literal windows-2025 or ubuntu-24.04 label."); + Assert.True(int.TryParse(YamlDocument.Scalar(job.Value, "timeout-minutes"), NumberStyles.None, CultureInfo.InvariantCulture, + out int minutes) && minutes > 0, + $"{path} job {job.Key} needs timeout-minutes."); + } + } + } + + [Fact] + public void Governance_workflows_start_read_only_and_comment_every_job_elevation() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + YamlDocument workflow = YamlDocument.Load(path); + Assert.Equal(new Dictionary(StringComparer.Ordinal) { ["contents"] = "read" }, + YamlDocument.Permissions(workflow.Root)); + string[] lines = RepositoryFile.ReadLines(path); + foreach (KeyValuePair job in workflow.Jobs) + { + if (YamlDocument.Child(job.Value, "permissions") is not YamlMappingNode permissions) + { + continue; + } + + foreach (KeyValuePair scope in permissions.Children) + { + string name = ((YamlScalarNode) scope.Key).Value ?? ""; + string access = ((YamlScalarNode) scope.Value).Value ?? ""; + if (string.Equals(name, "contents", StringComparison.Ordinal) + && string.Equals(access, "read", StringComparison.Ordinal)) + { + continue; + } + + string line = lines[(int) scope.Key.Start.Line - 1]; + Assert.True(line.Contains(" # ", StringComparison.Ordinal), + $"{path} job {job.Key}: '{name}: {access}' needs a trailing comment giving the reason (.coderabbit.yaml workflow hygiene)."); + } + } + } + } + + [Fact] + public void Governance_checkouts_never_persist_credentials() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + if (YamlDocument.UsesAction(step, "actions/checkout")) + { + Assert.True(string.Equals(YamlDocument.Scalar(YamlDocument.Child(step, "with"), "persist-credentials"), "false", + StringComparison.Ordinal), + $"{path} job {job.Key}: actions/checkout must set persist-credentials: false."); + } + } + } + } + } + + [Fact] + public void Governance_scripts_check_the_exit_code_of_every_native_command() + { + // A failing native command (or repository script) in the middle of a multi-line pwsh step does not stop the step: + // every one is followed by an explicit $LASTEXITCODE check (shared.md 7, the same rule as WorkflowContractTests). + List offenders = []; + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + string[] lines = (YamlDocument.Scalar(step, "run") ?? "").ReplaceLineEndings("\n").Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + if (!NativeInvocation().IsMatch(lines[i])) + { + continue; + } + + int next = i + 1; + while (next < lines.Length && string.IsNullOrWhiteSpace(lines[next])) + { + next++; + } + + if (next >= lines.Length || !lines[next].TrimStart().StartsWith("if ($LASTEXITCODE -ne 0)", StringComparison.Ordinal)) + { + offenders.Add($"{path} job {job.Key}: '{lines[i].Trim()}'"); + } + } + } + } + } + + Assert.True(offenders.Count == 0, + $"Follow every native command with if ($LASTEXITCODE -ne 0) {{ throw ... }}: {string.Join("; ", offenders)}"); + } + + [Fact] + public void Advisory_workflows_never_use_pull_request_target_or_merge_group() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + IReadOnlyList triggers = YamlDocument.Load(path).Triggers; + Assert.DoesNotContain("pull_request_target", triggers, StringComparer.Ordinal); + Assert.DoesNotContain("merge_group", triggers, StringComparer.Ordinal); + Assert.DoesNotContain("workflow_run", triggers, StringComparer.Ordinal); + } + } + + [Fact] + public void Governance_workflows_never_enable_a_package_cache() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + string? uses = YamlDocument.Uses(step); + Assert.False(uses is not null && uses.StartsWith("actions/cache", StringComparison.Ordinal), + $"{path} job {job.Key} uses actions/cache."); + YamlNode? with = YamlDocument.Child(step, "with"); + foreach (string key in (string[]) ["cache", "dependency-caching", "trap-caching"]) + { + Assert.False(string.Equals(YamlDocument.Scalar(with, key), "true", StringComparison.Ordinal), + $"{path} job {job.Key} enables '{key}': no cache on a path reachable by codeql or the audits."); + } + } + } + } + } + + [Fact] + public void Governance_workflows_upload_only_their_reserved_artifact_names() + { + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + if (YamlDocument.UsesAction(step, "actions/upload-artifact")) + { + string? name = YamlDocument.Scalar(YamlDocument.Child(step, "with"), "name"); + Assert.True(name is not null && Array.IndexOf(s_governanceArtifacts, name) >= 0, + $"{path} job {job.Key} uploads '{name}', which is not a reserved artifact name."); + } + } + } + } + } + + [Fact] + public void Codeql_analyzes_csharp_cpp_and_actions_with_literal_runner_labels() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.CodeQl); + + Dictionary expected = new(StringComparer.Ordinal) + { + ["csharp"] = ("csharp", "manual", "windows-2025"), + ["cpp"] = ("c-cpp", "none", "windows-2025"), + ["actions"] = ("actions", "none", "ubuntu-24.04") + }; + Assert.Equal(expected.Keys.Order(StringComparer.Ordinal), YamlDocument.KeysOf(YamlDocument.Child(workflow.Root, "jobs")).Order(StringComparer.Ordinal)); + foreach ((string id, (string language, string buildMode, string runner)) in expected) + { + YamlMappingNode job = workflow.Job(id); + Assert.Equal(runner, YamlDocument.Scalar(job, "runs-on")); + Assert.Equal($"Analyze ({language})", YamlDocument.Scalar(job, "name")); + IReadOnlyDictionary? permissions = YamlDocument.Permissions(job); + Assert.NotNull(permissions); + Assert.Equal("write", permissions["security-events"]); + + YamlNode? init = WithOf(job, "github/codeql-action/init"); + Assert.Equal(language, YamlDocument.Scalar(init, "languages")); + Assert.Equal(buildMode, YamlDocument.Scalar(init, "build-mode")); + Assert.Equal("security-extended", YamlDocument.Scalar(init, "queries")); + Assert.Equal($"/language:{language}", YamlDocument.Scalar(WithOf(job, "github/codeql-action/analyze"), "category")); + } + + // Python is not analysed: the repository has no Python source left, and a language without sources fails. + Assert.DoesNotContain("python", workflow.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Codeql_csharp_job_builds_the_product_graph_manually_without_shared_compilation() + { + YamlMappingNode job = YamlDocument.Load(GovernanceWorkflows.CodeQl).Job("csharp"); + IReadOnlyList steps = YamlDocument.Steps(job); + + YamlMappingNode checkout = Assert.Single(steps, static step => YamlDocument.UsesAction(step, "actions/checkout")); + Assert.Equal("0", YamlDocument.Scalar(YamlDocument.Child(checkout, "with"), "fetch-depth")); + YamlMappingNode setup = Assert.Single(steps, static step => + string.Equals(YamlDocument.Uses(step), "./.github/actions/setup-dotnet", StringComparison.Ordinal)); + Assert.Equal("src/CheatEngine.SDK/CheatEngine.SDK.csproj", YamlDocument.Scalar(YamlDocument.Child(setup, "with"), "restore")); + + YamlMappingNode build = Assert.Single(steps, static step => YamlDocument.Scalar(step, "run") is not null); + string run = YamlDocument.NormalizeWhitespace(YamlDocument.Scalar(build, "run")!); + foreach (string fragment in (string[]) + [ + "dotnet build src/CheatEngine.SDK/CheatEngine.SDK.csproj", "-c Release", "--no-restore", "--no-incremental", + "--disable-build-servers", "-p:UseSharedCompilation=false", "$LASTEXITCODE" + ]) + { + Assert.Contains(fragment, run, StringComparison.Ordinal); + } + + // The traced build runs between init and analyze. + int init = IndexOf(steps, "github/codeql-action/init"); + int analyze = IndexOf(steps, "github/codeql-action/analyze"); + int buildIndex = -1; + for (int i = 0; i < steps.Count; i++) + { + if (ReferenceEquals(steps[i], build)) + { + buildIndex = i; + } + } + + Assert.True(IndexOf(steps, "./.github/actions/setup-dotnet") < init && init < buildIndex && buildIndex < analyze, + "The CodeQL C# job must restore, then init, then build, then analyze."); + } + + [Fact] + public void Codeql_workflow_never_enables_a_package_cache() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.CodeQl); + Assert.DoesNotContain("actions/cache", workflow.Text, StringComparison.Ordinal); + foreach (KeyValuePair job in workflow.Jobs) + { + YamlNode? init = WithOf(job.Value, "github/codeql-action/init"); + Assert.Equal("false", YamlDocument.Scalar(init, "dependency-caching")); + Assert.Equal("false", YamlDocument.Scalar(init, "trap-caching")); + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + Assert.Null(YamlDocument.Child(YamlDocument.Child(step, "with"), "cache")); + } + } + } + + [Fact] + public void Codeql_runs_on_pull_requests_main_a_weekly_schedule_and_dispatch() + { + // A new workflow cannot be dispatched before it is on main: the pull_request trigger validates it on the vehicle + // pull request first (audit register PR-SEQ-15). + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.CodeQl); + Assert.Equal(["push", "pull_request", "schedule", "workflow_dispatch"], workflow.Triggers); + Assert.Equal(["main"], YamlDocument.Scalars(workflow.Trigger("push"), "branches")); + foreach (KeyValuePair job in workflow.Jobs) + { + Assert.Equal("${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}", + YamlDocument.Scalar(job.Value, "if")); + } + } + + private static YamlNode? WithOf(YamlMappingNode job, string action) + { + YamlMappingNode step = Assert.Single(YamlDocument.Steps(job), step => YamlDocument.UsesAction(step, action) + || string.Equals(YamlDocument.Uses(step), action, StringComparison.Ordinal)); + return YamlDocument.Child(step, "with"); + } + + private static int IndexOf(IReadOnlyList steps, string action) + { + for (int i = 0; i < steps.Count; i++) + { + if (YamlDocument.UsesAction(steps[i], action) + || string.Equals(YamlDocument.Uses(steps[i]), action, StringComparison.Ordinal)) + { + return i; + } + } + + return -1; + } + + [GeneratedRegex(@"^\s*(-\s+)?uses:\s+(?\S+)(?.*)$", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex UsesLine(); + + [GeneratedRegex(@"^[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex PinnedReference(); + + [GeneratedRegex(@"^\s+# v\d+\.\d+\.\d+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex VersionComment(); + + /// A line that starts a native command or a repository script (same pattern as WorkflowContractTests). + [GeneratedRegex(@"^\s*(?:dotnet|xmake|git|gh|tar)\s|^\s*\./|^\s*&\s", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex NativeInvocation(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index f4622215..2f4e941f 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -190,6 +190,20 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow `Compatibility_form_never_presents_a_profile_as_qualified_or_supported`, `Issue_form_element_ids_are_unique_and_valid`, `Issue_forms_disable_blank_issues_and_link_private_reporting`, `Issue_forms_never_instruct_editing_the_cheat_engine_runtime_configuration`). +- Every governance workflow pins its actions by full SHA with a version comment, uses literal `windows-2025` or + `ubuntu-24.04` runners with timeouts, starts from `contents: read` and comments every job-level elevation, never + persists checkout credentials, checks the exit code of every native command and script it runs, never uses + `pull_request_target`, `merge_group` or a package cache, and uploads only its reserved artifact names + (`GovernanceWorkflowTests`: `Governance_workflows_pin_every_action_by_full_sha_with_a_version_comment`, + `Governance_jobs_use_literal_runner_labels_and_timeouts`, `Governance_workflows_start_read_only_and_comment_every_job_elevation`, + `Governance_checkouts_never_persist_credentials`, `Governance_scripts_check_the_exit_code_of_every_native_command`, + `Advisory_workflows_never_use_pull_request_target_or_merge_group`, `Governance_workflows_never_enable_a_package_cache`, + `Governance_workflows_upload_only_their_reserved_artifact_names`). +- CodeQL analyses C# from a manual, traced, non-incremental Release build of the shipped product graph without the + compiler server, C/C++ and the workflows without a build, with no dependency or TRAP cache, on pull requests, `main`, + a weekly schedule and dispatch (`GovernanceWorkflowTests`: `Codeql_analyzes_csharp_cpp_and_actions_with_literal_runner_labels`, + `Codeql_csharp_job_builds_the_product_graph_manually_without_shared_compilation`, + `Codeql_workflow_never_enables_a_package_cache`, `Codeql_runs_on_pull_requests_main_a_weekly_schedule_and_dispatch`). ## Run the tests From 09c1424bf3a682a02717216d42cf083b87766123 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 03:26:33 +0200 Subject: [PATCH 067/199] Add OpenSSF Scorecard and online zizmor audits Two advisory workflows outside CI / Gate (audit register PR-CQ-24, PR-CQ-19): - scorecard.yml keeps exactly the shape the Scorecard publication verifier accepts (ossf/scorecard-infra verify_workflow.go): no workflow or job env/defaults, no run step, only allowlisted actions, an ubuntu-NN.NN runner, and id-token: write on the analysis job only. It is therefore the one workflow without the pwsh defaults. It runs on branch_protection_rule, pushes to main and weekly, publishes results and uploads SARIF to code scanning. There is no score target: the expected low checks (Binary-Artifacts for the two committed DLLs, Code-Review with a single maintainer, Branch-Protection until the settings script is applied) are explained, not chased. Publication works from the default branch only, so its first run is post-merge. - zizmor-online.yml runs the audits that need the GitHub API (known vulnerable actions, impostor commits, ref confusion) on pull requests, main, weekly (the advisory data changes without a commit) and on dispatch, pinning the zizmor version (1.30.1) next to the action SHA. In SARIF mode it reports to code scanning without failing. It skips drafts and fork pull requests, whose token cannot upload SARIF, and lets zizmor discover .github/zizmor.yml itself. GovernanceWorkflowTests pin the verifier rules, that only the Scorecard analysis job and the release workflow request an OIDC token (never at workflow level), and that every zizmor run pins the same version. --- .github/workflows/scorecard.yml | 52 ++++++++ .github/workflows/zizmor-online.yml | 56 +++++++++ CheatEngine.SDK.slnx | 2 + .../Governance/GovernanceWorkflowTests.cs | 118 ++++++++++++++++++ .../README.md | 6 + 5 files changed, 234 insertions(+) create mode 100644 .github/workflows/scorecard.yml create mode 100644 .github/workflows/zizmor-online.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..53ce3a7a --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,52 @@ +# Advisory OpenSSF Scorecard (audit register PR-CQ-24; https://github.com/ossf/scorecard-action). Not part of CI / Gate, +# no score target. With publish_results: true the Scorecard API verifies this file and rejects: workflow-level `env` +# or `defaults`, workflow-level write permissions, `id-token` in any other job, job-level `env` or `defaults`, +# containers and services, any step without `uses:` (so no `run:` step), actions outside its allowlist, and runners +# other than ubuntu-latest or ubuntu-NN.NN (NN.NN >= 22.04). This file therefore deliberately does NOT follow the +# repository's `defaults: run: shell: pwsh` convention. +# Publication works from the default branch only: the first run happens after merge. Expected low checks, explained +# rather than chased: Binary-Artifacts (the Lua fixture and the bridge, see SECURITY.md), Code-Review (single +# maintainer), Branch-Protection (until eng/github/Set-RepositorySettings.ps1 is applied). +name: Scorecard + +on: + branch_protection_rule: + push: + branches: [ main ] + schedule: + - cron: '23 4 * * 1' + +permissions: + contents: read + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + security-events: write # upload the SARIF results to code scanning + id-token: write # publish_results: sign the upload to the OpenSSF Scorecard API + actions: read # the Scorecard checks read workflow runs + issues: read # the Scorecard checks read issues + pull-requests: read # the Code-Review check reads pull requests + checks: read # the CI-Tests check reads check runs + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # No repo_token: the default token reads the rulesets of a public repository. + - name: Run Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/zizmor-online.yml b/.github/workflows/zizmor-online.yml new file mode 100644 index 00000000..9c91a118 --- /dev/null +++ b/.github/workflows/zizmor-online.yml @@ -0,0 +1,56 @@ +# Advisory online workflow audits (audit register PR-CQ-19): the audits that need the GitHub API +# (known-vulnerable-actions, impostor-commit, ref-confusion, ref-version-mismatch, stale-action-refs), uploaded to code +# scanning under the "zizmor" category. Not part of CI / Gate: in SARIF mode zizmor exits 0 with findings +# (https://docs.zizmor.sh/usage/). The blocking, offline zizmor run lives in ci.yml (job lint) and pins the same +# version. zizmor discovers .github/zizmor.yml by itself (https://docs.zizmor.sh/configuration/). +# The schedule matters: the vulnerable-actions data changes without any commit. +name: zizmor online audits + +on: + push: + branches: [ main ] + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + schedule: + - cron: '41 5 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + zizmor: + name: zizmor (online) + # Drafts wait; fork pull requests get a read-only token and cannot upload SARIF. + if: >- + github.event_name != 'pull_request' + || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-24.04 # the action runs zizmor in a container + timeout-minutes: 10 + permissions: + contents: read + security-events: write # upload zizmor SARIF + actions: read # the SARIF upload reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Inputs: https://github.com/zizmorcore/zizmor-action (action.yml at v0.6.4). The tool version is pinned next to + # the action, so a new zizmor release cannot change the findings without a commit. + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + online-audits: true + advanced-security: true + persona: regular diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 2b0b7c6e..242c0d90 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -35,9 +35,11 @@ + + diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs index 173148f5..80d3d121 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs @@ -12,6 +12,8 @@ namespace CheatEngine.SDK.Repository.Tests.Governance; /// public sealed partial class GovernanceWorkflowTests { + private const string ZizmorVersion = "1.30.1"; + private static readonly string[] s_runnerLabels = ["windows-2025", "ubuntu-24.04"]; /// Artifact names the governance workflows may upload (requested for shared-contracts §1.9). @@ -309,6 +311,122 @@ public void Codeql_runs_on_pull_requests_main_a_weekly_schedule_and_dispatch() } } + [Fact] + public void Scorecard_workflow_has_no_defaults_env_or_run_steps() + { + // The Scorecard API refuses to publish results of a workflow that breaks these rules, silently for the repository. + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.Scorecard); + Assert.Null(YamlDocument.Child(workflow.Root, "defaults")); + Assert.Null(YamlDocument.Child(workflow.Root, "env")); + + KeyValuePair job = Assert.Single(workflow.Jobs); + Assert.Equal("analysis", job.Key); + foreach (string key in (string[]) ["defaults", "env", "container", "services"]) + { + Assert.True(YamlDocument.Child(job.Value, key) is null, $"The Scorecard job must not set '{key}'."); + } + + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + Assert.Null(YamlDocument.Child(step, "run")); + Assert.NotNull(YamlDocument.Uses(step)); + } + + Assert.Equal("ubuntu-24.04", YamlDocument.Scalar(job.Value, "runs-on")); + Assert.Equal(["branch_protection_rule", "push", "schedule"], workflow.Triggers); + Assert.Equal(["main"], YamlDocument.Scalars(workflow.Trigger("push"), "branches")); + Assert.Equal("true", YamlDocument.Scalar(WithOf(job.Value, "ossf/scorecard-action"), "publish_results")); + } + + [Fact] + public void Scorecard_steps_use_only_the_actions_the_verifier_allows() + { + string[] allowed = + [ + "actions/checkout", "actions/create-github-app-token", "ossf/scorecard-action", "actions/upload-artifact", + "github/codeql-action/upload-sarif", "step-security/harden-runner" + ]; + foreach (YamlMappingNode step in YamlDocument.Steps(YamlDocument.Load(GovernanceWorkflows.Scorecard).Job("analysis"))) + { + string uses = YamlDocument.Uses(step) ?? ""; + string action = uses.Split('@')[0]; + Assert.True(Array.IndexOf(allowed, action) >= 0, $"The Scorecard verifier rejects the step '{uses}'."); + } + } + + [Fact] + public void Only_the_scorecard_job_requests_an_id_token() + { + // An OIDC token is a publication credential: the Scorecard upload among the governance workflows, and the release + // chain (NuGet trusted publishing, attestations) among the others. Never at workflow level. + List holders = []; + foreach (string path in GovernanceWorkflows.AllWorkflowPaths()) + { + YamlDocument workflow = YamlDocument.Load(path); + Assert.False(YamlDocument.Permissions(workflow.Root)?.ContainsKey("id-token") ?? false, + $"{path} requests id-token at workflow level."); + foreach (KeyValuePair job in workflow.Jobs) + { + if (YamlDocument.Permissions(job.Value)?.ContainsKey("id-token") ?? false) + { + holders.Add($"{path}#{job.Key}"); + } + } + } + + foreach (string holder in holders) + { + Assert.True(string.Equals(holder, GovernanceWorkflows.Scorecard + "#analysis", StringComparison.Ordinal) + || holder.StartsWith(".github/workflows/release.yml#", StringComparison.Ordinal), + $"{holder} requests an id-token; only the Scorecard analysis job and the release workflow may."); + } + + Assert.Contains(GovernanceWorkflows.Scorecard + "#analysis", holders, StringComparer.Ordinal); + } + + [Fact] + public void Zizmor_online_pins_the_tool_version_and_enables_online_audits() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.ZizmorOnline); + YamlMappingNode job = workflow.Job("zizmor"); + + YamlNode? with = WithOf(job, "zizmorcore/zizmor-action"); + Assert.Equal(ZizmorVersion, YamlDocument.Scalar(with, "version")); + Assert.Equal("true", YamlDocument.Scalar(with, "online-audits")); + Assert.Equal("true", YamlDocument.Scalar(with, "advanced-security")); + Assert.Equal("regular", YamlDocument.Scalar(with, "persona")); + // zizmor discovers .github/zizmor.yml itself; an explicit path would break before that file exists. + Assert.Null(YamlDocument.Child(with, "config")); + Assert.Equal("write", YamlDocument.Permissions(job)!["security-events"]); + + // Fork pull requests cannot upload SARIF, and drafts wait. + string condition = YamlDocument.NormalizeWhitespace(YamlDocument.Scalar(job, "if") ?? ""); + Assert.Contains("github.event.pull_request.head.repo.full_name == github.repository", condition, StringComparison.Ordinal); + Assert.Contains("!github.event.pull_request.draft", condition, StringComparison.Ordinal); + Assert.Equal(["push", "pull_request", "schedule", "workflow_dispatch"], workflow.Triggers); + } + + [Fact] + public void Online_and_gate_zizmor_runs_pin_the_same_version() + { + // The blocking offline run of ci.yml and this advisory run must judge the same rules. + foreach (string path in GovernanceWorkflows.AllWorkflowPaths()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + if (YamlDocument.UsesAction(step, "zizmorcore/zizmor-action")) + { + Assert.True(string.Equals(YamlDocument.Scalar(YamlDocument.Child(step, "with"), "version"), ZizmorVersion, + StringComparison.Ordinal), + $"{path} job {job.Key} must pin zizmor {ZizmorVersion} like {GovernanceWorkflows.ZizmorOnline}."); + } + } + } + } + } + private static YamlNode? WithOf(YamlMappingNode job, string action) { YamlMappingNode step = Assert.Single(YamlDocument.Steps(job), step => YamlDocument.UsesAction(step, action) diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index 2f4e941f..357800f1 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -204,6 +204,12 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow a weekly schedule and dispatch (`GovernanceWorkflowTests`: `Codeql_analyzes_csharp_cpp_and_actions_with_literal_runner_labels`, `Codeql_csharp_job_builds_the_product_graph_manually_without_shared_compilation`, `Codeql_workflow_never_enables_a_package_cache`, `Codeql_runs_on_pull_requests_main_a_weekly_schedule_and_dispatch`). +- Scorecard keeps the shape its publication verifier accepts (no `defaults`, `env` or `run` steps, allowlisted actions, + an Ubuntu runner), only its analysis job and the release workflow request an OIDC token, and the online zizmor run + pins the tool version the Gate uses, enables the online audits and skips forks and drafts (`GovernanceWorkflowTests`: + `Scorecard_workflow_has_no_defaults_env_or_run_steps`, `Scorecard_steps_use_only_the_actions_the_verifier_allows`, + `Only_the_scorecard_job_requests_an_id_token`, `Zizmor_online_pins_the_tool_version_and_enables_online_audits`, + `Online_and_gate_zizmor_runs_pin_the_same_version`). ## Run the tests From 290361a00cec9815c773ece776f9711c2f5cb93d Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 03:31:25 +0200 Subject: [PATCH 068/199] Submit the NuGet dependency graph from main The dependency graph resolves no NuGet version from the Central Package Management layout (the SBOM endpoint lists "Microsoft.CodeAnalysis.CSharp >= 0"), and automatic dependency submission needs an organization-level setting that cannot be enabled (shared.md 4; audit register PR-CQ-45). Dependabot alerts and dependency review therefore see almost nothing. dependency-submission.yml submits a snapshot built in CI, split in two jobs, following the binding decision that no unpinned action runs next to a write token: - detect (windows-2025, read-only token): eng/ci/New-DependencySnapshot.ps1 downloads Component Detection v8.0.1, verifies its SHA-256 against the release asset digest before running it, restores the solution and every project outside it with --locked-mode and a binary log each (the MSBuildBinaryLog detector marks the packages of test projects as development dependencies; the logs are never uploaded), scans with the NuGet detectors and converts the result into one manifest per project file with direct/indirect relationships, scopes and dependency edges. A sanity gate requires at least 40 packages and the Roslyn pin of Directory.Packages.props. - submit (ubuntu-24.04): the only governance job with contents: write. It runs no third-party code (download-artifact, then gh api) and refuses a snapshot whose shape, commit, ref or correlator is not this run's, because detect executed repository code. The official submission action is not used: it downloads the latest Component Detection release at run time. Pushes to main give dependency review its base snapshot; same-repository pull requests (Dependabot included, never forks or drafts) submit a head snapshot of the pull request head commit, as decided for the Client twin. A push to main is never cancelled by the next one, so no base commit loses its snapshot. A local run produced 37 project manifests and 103 distinct packages in 14 s (nothing was submitted). The tests run the submit step itself against six forged or valid snapshots with gh replaced by a recorder. --- .github/workflows/dependency-submission.yml | 123 +++++++ CheatEngine.SDK.slnx | 2 + eng/ci/New-DependencySnapshot.ps1 | 309 ++++++++++++++++++ .../Governance/GovernanceWorkflowTests.cs | 149 +++++++++ .../README.md | 8 + 5 files changed, 591 insertions(+) create mode 100644 .github/workflows/dependency-submission.yml create mode 100644 eng/ci/New-DependencySnapshot.ps1 diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml new file mode 100644 index 00000000..8d6dbd34 --- /dev/null +++ b/.github/workflows/dependency-submission.yml @@ -0,0 +1,123 @@ +# Advisory CI-side dependency submission (audit register PR-CQ-45). Automatic NuGet submission needs an +# organization-level setting that cannot be enabled here, and the dependency graph resolves no version from the Central +# Package Management layout on its own (it lists "Microsoft.CodeAnalysis.CSharp >= 0"). +# - detect: read-only token. A pinned, hash-verified Component Detection binary scans the locked restore +# (eng/ci/New-DependencySnapshot.ps1). The official submission action is not used: it downloads the latest Component +# Detection release at run time and executes it next to a contents: write token. +# - submit: the only job with contents: write. It executes no third-party code (download-artifact and gh api only) and +# refuses a snapshot whose commit, ref or correlator is not this run's, because detect ran repository code. +# Pushes to main give dependency review its base snapshot; same-repository pull requests (Dependabot included) submit +# a head snapshot for the pull request head commit. Fork pull requests and drafts never run it. +name: Dependency submission + +on: + push: + branches: [ main ] + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + workflow_dispatch: + +# Only the newest snapshot of a pull request matters. A push to main is never cancelled, so a quick second push does not +# leave the first commit, which later pull requests may use as their base, without a snapshot. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + detect: + name: Detect NuGet dependencies + if: >- + github.event_name != 'pull_request' + || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) + # Windows: the lock files of the Native AOT projects hold win-x64 ILCompiler sections, so a locked restore needs it. + runs-on: windows-2025 + timeout-minutes: 30 + steps: + # The snapshot describes the commit it names: the pull request head, not the merge commit. + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + # The script restores itself (locked, with binary logs for the detector). + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + + - name: Run Component Detection and build the snapshot + env: + SNAPSHOT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SNAPSHOT_REF: ${{ github.ref }} + SNAPSHOT_CORRELATOR: sdk-nuget + SNAPSHOT_JOB_ID: ${{ github.run_id }} + SNAPSHOT_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + ./eng/ci/New-DependencySnapshot.ps1 -OutputPath artifacts/dependency-snapshot/snapshot.json + if ($LASTEXITCODE -ne 0) { + throw "Dependency detection failed with exit code $LASTEXITCODE." + } + + - name: Upload snapshot + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dependency-snapshot + path: artifacts/dependency-snapshot/snapshot.json + if-no-files-found: error + retention-days: 5 + + submit: + name: Submit NuGet dependency graph + needs: detect + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write # POST /repos/{owner}/{repo}/dependency-graph/snapshots requires write + steps: + - name: Download snapshot + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dependency-snapshot + path: snapshot + + # The artifact came from a job that ran repository code: submit it only with the snapshot shape and this run's + # commit, ref and correlator. + - name: Submit + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SNAPSHOT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SNAPSHOT_REF: ${{ github.ref }} + SNAPSHOT_CORRELATOR: sdk-nuget + run: | + $ErrorActionPreference = 'Stop' + $snapshot = Get-Content -LiteralPath snapshot/snapshot.json -Raw | ConvertFrom-Json -AsHashtable + $properties = @($snapshot.Keys | Sort-Object) -join ',' + if ($properties -cne 'detector,job,manifests,ref,scanned,sha,version') { + throw "The snapshot artifact has unexpected top-level properties ($properties); nothing was submitted." + } + $job = $snapshot['job'] + $checks = [ordered]@{ + version = $snapshot['version'] -is [long] -and $snapshot['version'] -eq 0 + sha = $snapshot['sha'] -ceq $env:SNAPSHOT_SHA + ref = $snapshot['ref'] -ceq $env:SNAPSHOT_REF + correlator = $job -is [System.Collections.IDictionary] -and $job['correlator'] -ceq $env:SNAPSHOT_CORRELATOR + detector = $snapshot['detector'] -is [System.Collections.IDictionary] + manifests = $snapshot['manifests'] -is [System.Collections.IDictionary] -and $snapshot['manifests'].Count -gt 0 + } + $mismatches = @($checks.Keys | Where-Object { -not $checks[$_] }) + if ($mismatches.Count -gt 0) { + throw "The snapshot artifact does not match this run ($($mismatches -join ', ')); nothing was submitted." + } + gh api --method POST "repos/$env:REPOSITORY/dependency-graph/snapshots" --input snapshot/snapshot.json + if ($LASTEXITCODE -ne 0) { + throw "Dependency snapshot submission failed with exit code $LASTEXITCODE." + } + "Submitted $($snapshot['manifests'].Count) manifests for ``$env:SNAPSHOT_SHA`` ($env:SNAPSHOT_REF)." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 242c0d90..38292558 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -33,6 +33,7 @@ + @@ -70,6 +71,7 @@ + diff --git a/eng/ci/New-DependencySnapshot.ps1 b/eng/ci/New-DependencySnapshot.ps1 new file mode 100644 index 00000000..05b133bd --- /dev/null +++ b/eng/ci/New-DependencySnapshot.ps1 @@ -0,0 +1,309 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Detects the NuGet dependency graph of the locked restore with a pinned Component Detection binary and writes a + GitHub dependency snapshot. It never submits it. + +.DESCRIPTION + GitHub's dependency graph cannot read the Central Package Management layout (it lists NuGet packages as ">= 0"), and + automatic dependency submission needs an organization-level setting. The "Dependency submission" workflow therefore + submits a snapshot built here (https://docs.github.com/en/rest/dependency-graph/dependency-submission): + + 1. Download component-detection-win-x64.exe of microsoft/component-detection at a pinned release into a temporary + folder outside the repository and verify its SHA-256 before running it. The official submission action is not + used: it downloads the latest release at run time and runs it next to a contents: write token. + 2. Restore CheatEngine.SDK.slnx, then every project outside it (enumerated from git, like eng/Update-LockFiles.ps1), + with --locked-mode and one binary log each under artifacts/logs. The default-on MSBuildBinaryLog detector reads + project.assets.json (under artifacts/obj because of ArtifactsPath) and uses the binary logs to mark the packages + of test projects (IsTestProject) as development dependencies. Binary logs can hold environment variables: they + stay on the machine and are never uploaded. + 3. Scan the repository with the NuGet detectors. + 4. Convert the scan manifest into the snapshot body: one manifest per project file (repository-relative, forward + slashes), "direct" for the packages the project references explicitly, "development" scope for the detector's + development dependencies, and the dependency edges of each project graph. Non-NuGet components are skipped. + 5. Sanity gate: at least $MinimumPackageCount distinct packages, and Microsoft.CodeAnalysis.CSharp at the version + Directory.Packages.props pins (the Roslyn floor every analyzer and generator builds against). + +.PARAMETER OutputPath + Where the snapshot JSON is written (relative to the repository root, or absolute). + +.PARAMETER Sha + The commit the snapshot describes (40 or 64 lowercase hex). Defaults to $env:SNAPSHOT_SHA, else the local HEAD. + +.PARAMETER Ref + The Git ref of the snapshot (refs/...). Defaults to $env:SNAPSHOT_REF, else the local symbolic HEAD. + +.PARAMETER Correlator + Groups the snapshots of this detection over time. Defaults to $env:SNAPSHOT_CORRELATOR, else 'local-nuget'. + +.PARAMETER JobId + Run identifier. Defaults to $env:SNAPSHOT_JOB_ID, else 'local'. + +.PARAMETER JobUrl + Run URL (optional). Defaults to $env:SNAPSHOT_JOB_URL. + +.EXAMPLE + ./eng/ci/New-DependencySnapshot.ps1 -OutputPath "$env:TEMP/snapshot.json" + + Builds a snapshot of the current checkout. Nothing is sent to GitHub. +#> +[CmdletBinding()] +param( + [string] $OutputPath = 'artifacts/dependency-snapshot/snapshot.json', + [string] $Sha = $env:SNAPSHOT_SHA, + [string] $Ref = $env:SNAPSHOT_REF, + [string] $Correlator = $env:SNAPSHOT_CORRELATOR, + [string] $JobId = $env:SNAPSHOT_JOB_ID, + [string] $JobUrl = $env:SNAPSHOT_JOB_URL +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Pinned detector. Bump the three values together after reading the release and its asset digest: +# gh api repos/microsoft/component-detection/releases/tags/v --jq '.assets[] | select(.name == "component-detection-win-x64.exe") | .digest' +$DetectorVersion = '8.0.1' +$DetectorAsset = 'component-detection-win-x64.exe' +$DetectorSha256 = '9539f792cd2ae7d719922db45df763ec4454cc380fbfcbe685da9a90c1b39cf2' +$DetectorUrl = "https://github.com/microsoft/component-detection/releases/download/v$DetectorVersion/$DetectorAsset" +$MinimumPackageCount = 40 +$SentinelPackage = 'Microsoft.CodeAnalysis.CSharp' + +$RepositoryRoot = Split-Path -Path $PSScriptRoot -Parent | Split-Path -Parent +$SolutionFile = 'CheatEngine.SDK.slnx' +$InvariantCulture = [System.Globalization.CultureInfo]::InvariantCulture + +function Invoke-Native { + param( + [Parameter(Mandatory)] [string] $FilePath, + [Parameter(Mandatory)] [string[]] $ArgumentList, + [Parameter(Mandatory)] [string] $Description + ) + + Write-Host "> $([System.IO.Path]::GetFileName($FilePath)) $($ArgumentList -join ' ')" + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "$Description failed with exit code $LASTEXITCODE." + } +} + +function Get-GitOutput { + param( + [Parameter(Mandatory)] [string[]] $ArgumentList + ) + + $output = & git -C $RepositoryRoot @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "git $($ArgumentList -join ' ') failed with exit code $LASTEXITCODE." + } + + return @($output | Where-Object { $_ }) +} + +function Get-OutOfSolutionProject { + [xml] $solution = Get-Content -Raw -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath $SolutionFile) + $inSolution = @($solution.SelectNodes('//Project') | ForEach-Object { $_.GetAttribute('Path').Replace('\', '/') }) + return @(Get-GitOutput -ArgumentList @('ls-files', '--', '*.csproj') | + Where-Object { $_ -notin $inSolution } | + Sort-Object) +} + +function Get-PinnedVersion { + param( + [Parameter(Mandatory)] [string] $PackageId + ) + + [xml] $packages = Get-Content -Raw -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath 'Directory.Packages.props') + foreach ($version in $packages.SelectNodes('//PackageVersion')) { + if ($version.GetAttribute('Include') -ceq $PackageId) { + return $version.GetAttribute('Version') + } + } + + throw "Directory.Packages.props pins no version of $PackageId." +} + +function ConvertTo-OrdinalSet { + param( + [AllowNull()] [object] $Item + ) + + $values = [string[]] @($Item | Where-Object { $null -ne $_ }) + $set = [System.Collections.Generic.HashSet[string]]::new($values, [System.StringComparer]::Ordinal) + # The unary comma keeps the set whole: PowerShell would otherwise enumerate it (an empty set would become $null). + return , $set +} + +function Get-PackageUrl { + param( + [Parameter(Mandatory)] [object] $Component + ) + + # Package URL spec: the NuGet type keeps the name's case; '@' in a name must be percent-encoded. + return "pkg:nuget/$($Component.Name.Replace('@', '%40'))@$($Component.Version)" +} + +function ConvertTo-RepositoryPath { + param( + [Parameter(Mandatory)] [string] $Location + ) + + $absolute = if ([System.IO.Path]::IsPathRooted($Location)) { $Location } else { Join-Path -Path $RepositoryRoot -ChildPath $Location } + $relative = [System.IO.Path]::GetRelativePath($RepositoryRoot, $absolute).Replace('\', '/') + if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or [System.IO.Path]::IsPathRooted($relative)) { + return $null + } + + return $relative +} + +if (-not $Sha) { $Sha = @(Get-GitOutput -ArgumentList @('rev-parse', 'HEAD'))[0] } +if (-not $Ref) { $Ref = @(Get-GitOutput -ArgumentList @('symbolic-ref', '--quiet', 'HEAD'))[0] } +if (-not $Correlator) { $Correlator = 'local-nuget' } +if (-not $JobId) { $JobId = 'local' } +if (-not [regex]::IsMatch([string] $Sha, '^[0-9a-f]{40}([0-9a-f]{24})?$')) { + throw "The snapshot commit must be a full lowercase commit id; got '$Sha'." +} + +if (-not ([string] $Ref).StartsWith('refs/', [StringComparison]::Ordinal)) { + throw "The snapshot ref must start with refs/; got '$Ref'." +} + +$work = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "cheatengine-sdk-snapshot-$([guid]::NewGuid().ToString('N'))" +$detectorLogs = Join-Path -Path $work -ChildPath 'logs' +New-Item -ItemType Directory -Path $detectorLogs | Out-Null +try { + $detector = Join-Path -Path $work -ChildPath $DetectorAsset + $scanManifest = Join-Path -Path $work -ChildPath 'component-detection-manifest.json' + + Write-Host "Downloading Component Detection v$DetectorVersion." + Invoke-WebRequest -Uri $DetectorUrl -OutFile $detector -MaximumRetryCount 3 -RetryIntervalSec 5 + $actualSha256 = (Get-FileHash -LiteralPath $detector -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha256 -cne $DetectorSha256) { + throw "$DetectorAsset v$DetectorVersion has SHA-256 $actualSha256, expected $DetectorSha256. Refusing to run it." + } + + $logDirectory = Join-Path -Path $RepositoryRoot -ChildPath 'artifacts/logs' + New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null + $targets = @($SolutionFile) + @(Get-OutOfSolutionProject) + Push-Location -LiteralPath $RepositoryRoot + try { + foreach ($target in $targets) { + $binaryLog = Join-Path -Path $logDirectory -ChildPath "dependency-restore-$([System.IO.Path]::GetFileNameWithoutExtension($target)).binlog" + Invoke-Native -FilePath 'dotnet' -ArgumentList @('restore', $target, '--locked-mode', "-bl:$binaryLog") ` + -Description "Locked restore of $target" + } + } + finally { + Pop-Location + } + + Invoke-Native -FilePath $detector -ArgumentList @( + 'scan', '--SourceDirectory', $RepositoryRoot, '--ManifestFile', $scanManifest, + '--DetectorCategories', 'NuGet', '--Output', $detectorLogs, '--LogLevel', 'Warning' + ) -Description 'Component Detection' + + $scan = Get-Content -LiteralPath $scanManifest -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable + $components = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + foreach ($found in @($scan['componentsFound'])) { + $component = $found['component'] + if ($component['type'] -ceq 'NuGet') { + $components[[string] $component['id']] = [pscustomobject]@{ + Name = [string] $component['name'] + Version = [string] $component['version'] + } + } + } + + $manifests = [ordered]@{} + foreach ($location in @($scan['dependencyGraphs'].Keys | Sort-Object)) { + $relative = ConvertTo-RepositoryPath -Location $location + if ($null -eq $relative) { + continue + } + + $graph = $scan['dependencyGraphs'][$location] + $explicit = ConvertTo-OrdinalSet -Item $graph['explicitlyReferencedComponentIds'] + $development = ConvertTo-OrdinalSet -Item $graph['developmentDependencies'] + $resolved = [ordered]@{} + foreach ($id in @($graph['graph'].Keys | Sort-Object)) { + if (-not $components.ContainsKey($id)) { + continue + } + + $children = @(@($graph['graph'][$id]) | + Where-Object { $_ -and $components.ContainsKey($_) } | + ForEach-Object { Get-PackageUrl -Component $components[$_] } | + Sort-Object -Unique) + $packageUrl = Get-PackageUrl -Component $components[$id] + $resolved[$packageUrl] = [ordered]@{ + package_url = $packageUrl + relationship = if ($explicit.Contains($id)) { 'direct' } else { 'indirect' } + scope = if ($development.Contains($id)) { 'development' } else { 'runtime' } + dependencies = [string[]] $children + } + } + + if ($resolved.Count -gt 0) { + $manifests[$relative] = [ordered]@{ + name = $relative + file = [ordered]@{ source_location = $relative } + resolved = $resolved + } + } + } +} +finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} + +$job = [ordered]@{ correlator = $Correlator; id = $JobId } +if ($JobUrl) { + $job['html_url'] = $JobUrl +} + +$snapshot = [ordered]@{ + version = 0 + sha = $Sha + ref = $Ref + job = $job + detector = [ordered]@{ + name = 'Microsoft Component Detection' + version = $DetectorVersion + url = 'https://github.com/microsoft/component-detection' + } + scanned = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', $InvariantCulture) + manifests = $manifests +} + +$packageUrls = @($manifests.Values | ForEach-Object { $_['resolved'].Keys } | Sort-Object -Unique) +$entries = @($manifests.Values | ForEach-Object { $_['resolved'].Values }) +$direct = @($entries | Where-Object { $_['relationship'] -ceq 'direct' }) +$developmentEntries = @($entries | Where-Object { $_['scope'] -ceq 'development' }) +$sentinelUrl = "pkg:nuget/$SentinelPackage@$(Get-PinnedVersion -PackageId $SentinelPackage)" +$lines = @( + '## Dependency snapshot', + '', + "Component Detection v$DetectorVersion (SHA-256 verified). Commit ``$Sha``, ref ``$Ref``, correlator ``$Correlator``.", + '', + '| Manifests | Distinct NuGet packages | Direct references | Development entries |', + '| --- | --- | --- | --- |', + "| $($manifests.Count) | $($packageUrls.Count) | $($direct.Count) | $($developmentEntries.Count) |" +) +$lines | ForEach-Object { Write-Host $_ } +if ($env:GITHUB_STEP_SUMMARY) { + $lines | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +} + +if ($packageUrls.Count -lt $MinimumPackageCount) { + throw "Only $($packageUrls.Count) NuGet packages detected (at least $MinimumPackageCount expected): the scan missed the restore." +} + +if ($packageUrls -cnotcontains $sentinelUrl) { + throw "The snapshot lacks $sentinelUrl, the Roslyn pin of Directory.Packages.props: the scan missed the analyzers and generators." +} + +$output = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path -Path $RepositoryRoot -ChildPath $OutputPath } +New-Item -ItemType Directory -Force -Path (Split-Path -Path $output -Parent) | Out-Null +[System.IO.File]::WriteAllText($output, ($snapshot | ConvertTo-Json -Depth 20), [System.Text.UTF8Encoding]::new($false)) +Write-Host "Snapshot written to $output." diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs index 80d3d121..60d1b045 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.Json; using System.Text.RegularExpressions; using YamlDotNet.RepresentationModel; @@ -427,6 +428,154 @@ public void Online_and_gate_zizmor_runs_pin_the_same_version() } } + [Fact] + public void Dependency_submission_runs_on_main_dispatch_and_same_repository_pull_requests_only() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.DependencySubmission); + + Assert.Equal(["push", "pull_request", "workflow_dispatch"], workflow.Triggers); + Assert.Equal(["main"], YamlDocument.Scalars(workflow.Trigger("push"), "branches")); + string condition = YamlDocument.NormalizeWhitespace(YamlDocument.Scalar(workflow.Job("detect"), "if") ?? ""); + Assert.Equal( + "github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository)", + condition); + // submit only follows a successful detect, so it inherits the fork and draft exclusion. + Assert.Equal(["detect"], YamlDocument.Scalars(workflow.Job("submit"), "needs")); + Assert.Null(YamlDocument.Child(workflow.Job("submit"), "if")); + } + + [Fact] + public void Only_the_dependency_submit_job_holds_contents_write() + { + List writers = []; + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + if (YamlDocument.Permissions(job.Value) is { } permissions + && permissions.TryGetValue("contents", out string? access) + && string.Equals(access, "write", StringComparison.Ordinal)) + { + writers.Add($"{path}#{job.Key}"); + } + } + } + + Assert.Equal([GovernanceWorkflows.DependencySubmission + "#submit"], writers); + Assert.Null(YamlDocument.Permissions(YamlDocument.Load(GovernanceWorkflows.DependencySubmission).Job("detect"))); + } + + [Fact] + public void Dependency_submit_job_runs_no_third_party_code() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.DependencySubmission); + IReadOnlyList steps = YamlDocument.Steps(workflow.Job("submit")); + + Assert.Equal(2, steps.Count); + Assert.True(YamlDocument.UsesAction(steps[0], "actions/download-artifact"), "submit starts by downloading the snapshot."); + Assert.Equal("dependency-snapshot", YamlDocument.Scalar(YamlDocument.Child(steps[0], "with"), "name")); + Assert.Null(YamlDocument.Uses(steps[1])); + string run = YamlDocument.Scalar(steps[1], "run") ?? ""; + Assert.Contains("gh api --method POST", run, StringComparison.Ordinal); + + // Detection and submission name the same commit, ref and correlator. + YamlMappingNode detect = Assert.Single(YamlDocument.Steps(workflow.Job("detect")), + static step => YamlDocument.Child(step, "env") is not null); + foreach (string variable in (string[]) ["SNAPSHOT_SHA", "SNAPSHOT_REF", "SNAPSHOT_CORRELATOR"]) + { + Assert.Equal(YamlDocument.Scalar(YamlDocument.Child(detect, "env"), variable), + YamlDocument.Scalar(YamlDocument.Child(steps[1], "env"), variable)); + } + + // The official action fetches the latest Component Detection at run time next to the write token. + Assert.DoesNotContain("component-detection-dependency-submission-action", workflow.Text, StringComparison.Ordinal); + } + + [Fact] + public void Dependency_detection_uses_a_pinned_hash_verified_component_detection() + { + string script = RepositoryFile.ReadText("eng/ci/New-DependencySnapshot.ps1"); + + Assert.Matches(new Regex(@"^\$DetectorVersion = '\d+\.\d+\.\d+'\r?$", RegexOptions.Multiline, TimeSpan.FromSeconds(1)), script); + Assert.Matches(new Regex(@"^\$DetectorSha256 = '[0-9a-f]{64}'\r?$", RegexOptions.Multiline, TimeSpan.FromSeconds(1)), script); + Assert.Contains("releases/download/v$DetectorVersion/$DetectorAsset", script, StringComparison.Ordinal); + Assert.Contains("Get-FileHash -LiteralPath $detector -Algorithm SHA256", script, StringComparison.Ordinal); + Assert.Contains("'--locked-mode'", script, StringComparison.Ordinal); + Assert.DoesNotContain("releases/latest", script, StringComparison.Ordinal); + Assert.DoesNotContain("dependency-graph/snapshots", script, StringComparison.Ordinal); + } + + [Theory] + [InlineData("valid", true)] + [InlineData("other_commit", false)] + [InlineData("other_ref", false)] + [InlineData("other_correlator", false)] + [InlineData("extra_property", false)] + [InlineData("no_manifest", false)] + public async Task Dependency_submit_step_submits_only_a_snapshot_of_this_run(string variant, bool submitted) + { + const string Sha = "0123456789abcdef0123456789abcdef01234567"; + const string Ref = "refs/heads/main"; + using TemporaryDirectory directory = new(); + Directory.CreateDirectory(directory.File("snapshot")); + Dictionary snapshot = new(StringComparer.Ordinal) + { + ["version"] = 0, + ["sha"] = Is(variant, "other_commit") ? new string('f', 40) : Sha, + ["ref"] = Is(variant, "other_ref") ? "refs/heads/feature" : Ref, + ["job"] = new Dictionary(StringComparer.Ordinal) + { + ["correlator"] = Is(variant, "other_correlator") ? "other" : "sdk-nuget", + ["id"] = "1" + }, + ["detector"] = new Dictionary(StringComparer.Ordinal) { ["name"] = "d", ["version"] = "1", ["url"] = "u" }, + ["scanned"] = "2026-09-23T00:00:00Z", + ["manifests"] = Is(variant, "no_manifest") + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal) { ["src/A.csproj"] = new Dictionary(StringComparer.Ordinal) } + }; + if (Is(variant, "extra_property")) + { + snapshot["extra"] = 1; + } + + await File.WriteAllTextAsync(directory.File("snapshot/snapshot.json"), JsonSerializer.Serialize(snapshot), + TestContext.Current.CancellationToken); + + YamlMappingNode submit = YamlDocument.Steps(YamlDocument.Load(GovernanceWorkflows.DependencySubmission).Job("submit"))[1]; + string marker = directory.File("gh-called.txt"); + string script = $"Set-Location -LiteralPath {PwshScript.Quote(directory.Path)}" + Environment.NewLine + + $"function gh {{ $args -join ' ' | Set-Content -LiteralPath {PwshScript.Quote(marker)}; $global:LASTEXITCODE = 0 }}" + + Environment.NewLine + YamlDocument.Scalar(submit, "run"); + Dictionary environment = new(StringComparer.Ordinal) + { + ["REPOSITORY"] = "CheatEngineNet/CheatEngine.SDK", + ["SNAPSHOT_SHA"] = Sha, + ["SNAPSHOT_REF"] = Ref, + ["SNAPSHOT_CORRELATOR"] = "sdk-nuget", + ["GITHUB_STEP_SUMMARY"] = directory.File("summary.md") + }; + + PwshResult run = await PwshScript.RunTextAsync(script, environment); + + Assert.True(submitted == (run.ExitCode == 0), run.Transcript); + Assert.Equal(submitted, File.Exists(marker)); + if (submitted) + { + Assert.StartsWith("api --method POST repos/CheatEngineNet/CheatEngine.SDK/dependency-graph/snapshots", + await File.ReadAllTextAsync(marker, TestContext.Current.CancellationToken), StringComparison.Ordinal); + } + else + { + Assert.Contains("nothing was submitted", run.StandardError + run.StandardOutput, StringComparison.Ordinal); + } + } + + private static bool Is(string value, string expected) + { + return string.Equals(value, expected, StringComparison.Ordinal); + } + private static YamlNode? WithOf(YamlMappingNode job, string action) { YamlMappingNode step = Assert.Single(YamlDocument.Steps(job), step => YamlDocument.UsesAction(step, action) diff --git a/tests/CheatEngine.SDK.Repository.Tests/README.md b/tests/CheatEngine.SDK.Repository.Tests/README.md index 357800f1..93cd3bf3 100644 --- a/tests/CheatEngine.SDK.Repository.Tests/README.md +++ b/tests/CheatEngine.SDK.Repository.Tests/README.md @@ -210,6 +210,14 @@ Later work adds one folder per contract (for example `Documentation/`, `Workflow `Scorecard_workflow_has_no_defaults_env_or_run_steps`, `Scorecard_steps_use_only_the_actions_the_verifier_allows`, `Only_the_scorecard_job_requests_an_id_token`, `Zizmor_online_pins_the_tool_version_and_enables_online_audits`, `Online_and_gate_zizmor_runs_pin_the_same_version`). +- Dependency submission detects on a read-only token with a pinned, hash-verified Component Detection over the locked + restore, and submits from a separate job that is the only governance job holding `contents: write`, runs no + third-party code and refuses a snapshot of another commit, ref or correlator; it runs on `main`, dispatch and + same-repository pull requests only (`GovernanceWorkflowTests`: + `Dependency_submission_runs_on_main_dispatch_and_same_repository_pull_requests_only`, + `Only_the_dependency_submit_job_holds_contents_write`, `Dependency_submit_job_runs_no_third_party_code`, + `Dependency_detection_uses_a_pinned_hash_verified_component_detection`, + `Dependency_submit_step_submits_only_a_snapshot_of_this_run`). ## Run the tests From 424c93338dc5d48f5c281d37db0e011ffba37527 Mon Sep 17 00:00:00 2001 From: AriusII Date: Wed, 23 Sep 2026 05:35:46 +0200 Subject: [PATCH 069/199] Add a weekly scheduled health workflow Nothing watched what changes without a commit of this repository: new advisories, a newer .NET SDK, the toolchain that rebuilds the released bridge, intermittent test failures and external links (audit register PR-CQ-55, PR-CQ-30, A21-25; audit ch.21 exit criteria: the package can be rebuilt from its release commit, version and content locked). scheduled-health.yml runs on Mondays and on dispatch, outside CI / Gate, one run at a time: - audit: restores the solution and every project outside it with --locked-mode --force -p:AuditPipeline=true (the repository's dedicated audit property: every NU1900-NU1905 code becomes an error), then lists vulnerable and deprecated packages as JSON with --no-restore. Any vulnerability of any severity fails. - canary: Set-CanarySdkVersion.ps1 writes latest-sdk of the channel's release metadata into global.json before the composite action installs it (only sdk.version changes: the MTP test runner section stays), then Invoke-SdkCanary.ps1 regenerates the lock files with eng/Update-LockFiles.ps1, keeps lock-files.patch for the Dependabot dotnet-sdk pull request, builds, packs and runs the Release tests against the packed file (CESDK_PACKAGED_UMBRELLA_NUPKG). With the pinned SDK a non-empty patch fails: the committed locks are stale. The pin is a separate script because it runs before .NET is installed. - test-repeat: the Lua, Lua.Interop and Hosting modules (NativeLua traits) five times in Debug with --fail-skips on; hang dumps once eng/Tests.props references the extension. Required runs never retry. - bridge-drift: rebuilds the newest release tag's bridge from its committed .c and xmake.lua bytes, twice, in two directories outside the checkout, with --ccache=n and the toolchain pins of the native job, and compares with the DLL committed at the tag and the one inside the nuget.org package: Reproduced, ToolchainDrift (warning) or Failed. - links: external Markdown links, 404/410 broken, the rest inconclusive; never fails and never gates a pull request. - notify (scheduled runs only, the one issues: write job): opens or comments on a single "Scheduled health check needs attention" issue, built only from job ids, results, two booleans and the run URL. Decisions live in the pure HealthCheck.psm1; HealthCheckScriptTests run its vectors in one pwsh process, and the canary pin and the issue publisher end to end (gh replaced by a recorder). GovernanceWorkflowTests pin the triggers, the issue permission, the pin-before-setup order, the composite action for every .NET job, the toolchain pins shared with the native job and the artifact names. GovernanceScriptSyntaxTests parses every script under eng/ci and eng/github: PSScriptAnalyzer's Error and Warning profile does not report syntax errors, and these scripts mostly run weekly or after merge. Local runs: audit passed (no vulnerable or deprecated package); the v1.0.0 bridge rebuild exports 8a63e00c...:28713685..., the committed and released DLLs are both da08c2ba...d994, and the local MSVC 14.51 and 14.44.35207 give ToolchainDrift; test-repeat 2 x 3 modules passed; 42 external links ok. https://learn.microsoft.com/nuget/concepts/auditing-packages#running-nuget-audit-in-ci https://learn.microsoft.com/dotnet/core/tools/dotnet-package-list https://learn.microsoft.com/dotnet/core/tools/global-json --- .github/workflows/scheduled-health.yml | 220 +++++++ CheatEngine.SDK.slnx | 13 + eng/ci/health/HealthCheck.psm1 | 564 ++++++++++++++++++ eng/ci/health/HealthCommand.psm1 | 230 +++++++ eng/ci/health/Invoke-BridgeDriftCheck.ps1 | 371 ++++++++++++ eng/ci/health/Invoke-SdkCanary.ps1 | 172 ++++++ eng/ci/health/Invoke-TestRepeat.ps1 | 124 ++++ eng/ci/health/Invoke-VulnerabilityAudit.ps1 | 130 ++++ eng/ci/health/Publish-HealthIssue.ps1 | 98 +++ eng/ci/health/README.md | 137 +++++ eng/ci/health/Set-CanarySdkVersion.ps1 | 83 +++ eng/ci/health/Test-ExternalLink.ps1 | 126 ++++ .../Governance/GovernanceScriptSyntaxTests.cs | 52 ++ ...GovernanceWorkflowTests.ScheduledHealth.cs | 242 ++++++++ .../Governance/HealthCheckCases.cs | 229 +++++++ .../Governance/HealthCheckFixture.cs | 50 ++ .../Governance/HealthCheckScriptTests.cs | 425 +++++++++++++ .../Governance/PwshCall.cs | 4 + .../Governance/PwshCallResult.cs | 25 + .../Governance/PwshFunctionBatch.cs | 69 +++ .../Governance/PwshScript.cs | 11 +- .../README.md | 24 + 22 files changed, 3395 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/scheduled-health.yml create mode 100644 eng/ci/health/HealthCheck.psm1 create mode 100644 eng/ci/health/HealthCommand.psm1 create mode 100644 eng/ci/health/Invoke-BridgeDriftCheck.ps1 create mode 100644 eng/ci/health/Invoke-SdkCanary.ps1 create mode 100644 eng/ci/health/Invoke-TestRepeat.ps1 create mode 100644 eng/ci/health/Invoke-VulnerabilityAudit.ps1 create mode 100644 eng/ci/health/Publish-HealthIssue.ps1 create mode 100644 eng/ci/health/README.md create mode 100644 eng/ci/health/Set-CanarySdkVersion.ps1 create mode 100644 eng/ci/health/Test-ExternalLink.ps1 create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceScriptSyntaxTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.ScheduledHealth.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/HealthCheckCases.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/HealthCheckFixture.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/HealthCheckScriptTests.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PwshCall.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PwshCallResult.cs create mode 100644 tests/CheatEngine.SDK.Repository.Tests/Governance/PwshFunctionBatch.cs diff --git a/.github/workflows/scheduled-health.yml b/.github/workflows/scheduled-health.yml new file mode 100644 index 00000000..16b9c316 --- /dev/null +++ b/.github/workflows/scheduled-health.yml @@ -0,0 +1,220 @@ +# Advisory weekly health checks (audit register PR-CQ-55, PR-CQ-30, A21-25). Not part of CI / Gate: they watch what +# changes without a commit of this repository. +# - audit: every package, direct and transitive, at every severity (NU1900-NU1905 are errors only here). +# - canary: the newest .NET SDK of the pinned channel regenerates the lock files, builds, packs and tests; the lock-file +# patch is the artifact a maintainer applies to the Dependabot dotnet-sdk pull request. +# - test-repeat: the threading-sensitive NativeLua modules, five runs in a row (flaky-test policy). +# - bridge-drift: the latest release tag's native bridge rebuilt with today's toolchain, compared with the released DLL. +# - links: external links of the Markdown files (never checked in a pull request gate). +# - notify: on scheduled runs only, opens or updates one issue when anything above needs attention. +# How to act on each job: eng/ci/health/README.md. GitHub disables a scheduled workflow after 60 days without +# repository activity, and a dispatch needs this file on the default branch. +name: Scheduled health + +on: + schedule: + - cron: '37 2 * * 1' + workflow_dispatch: + +# One health run at a time; a second trigger waits instead of cancelling a run that is about to report. +concurrency: + group: scheduled-health + cancel-in-progress: false + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + audit: + name: Strict NuGet audit + # Windows: the lock files of the Native AOT projects hold win-x64 ILCompiler sections, so a locked restore needs it. + runs-on: windows-2025 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The script restores every project itself (locked, forced, with the strict audit properties). + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + + - name: Audit every package at every severity + run: | + ./eng/ci/health/Invoke-VulnerabilityAudit.ps1 -OutputDirectory artifacts/health/audit + if ($LASTEXITCODE -ne 0) { + throw "The strict NuGet audit failed with exit code $LASTEXITCODE." + } + + canary: + name: Newest .NET SDK canary + runs-on: windows-2025 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # MinVer versions the package, and the canary diffs the lock files against HEAD + persist-credentials: false + + # Before the composite action, which installs exactly the SDK global.json names (rollForward: disable). Only + # sdk.version changes, in this checkout only; the test runner section stays. + - name: Select the newest SDK of the channel + id: sdk + run: | + ./eng/ci/health/Set-CanarySdkVersion.ps1 + if ($LASTEXITCODE -ne 0) { + throw "Selecting the canary SDK failed with exit code $LASTEXITCODE." + } + + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + + - name: Regenerate lock files, build, pack and test + run: | + ./eng/ci/health/Invoke-SdkCanary.ps1 -OutputDirectory artifacts/health/sdk-canary + if ($LASTEXITCODE -ne 0) { + throw "The SDK canary failed with exit code $LASTEXITCODE." + } + + - name: Upload canary results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: health-sdk-canary + path: | + artifacts/health/sdk-canary/lock-files.patch + artifacts/health/sdk-canary/summary.md + artifacts/health/sdk-canary/test-results/**/*.trx + if-no-files-found: warn + retention-days: 14 + + test-repeat: + name: Repeat threading-sensitive tests + runs-on: windows-2025 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # MinVer computes the version of the libraries the test modules build + persist-credentials: false + + # The script restores the three test projects itself, in locked mode. + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + + - name: Run the NativeLua modules five times + run: | + ./eng/ci/health/Invoke-TestRepeat.ps1 -Iterations 5 -OutputDirectory artifacts/health/test-repeat + if ($LASTEXITCODE -ne 0) { + throw "The repeated test runs failed with exit code $LASTEXITCODE." + } + + - name: Upload failed runs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: health-test-repeat + path: artifacts/health/test-repeat + if-no-files-found: ignore + retention-days: 14 + + bridge-drift: + name: Release bridge rebuild + runs-on: windows-2025 + timeout-minutes: 20 + # Keep in sync with the ci.yml native job: the same toolchain pins, so a drift means today's pinned CI toolchain no + # longer rebuilds the released bytes. GovernanceWorkflowTests compares both jobs. + env: + BRIDGE_VS_TOOLSET: '14.44' + BRIDGE_VS_SDKVER: '10.0.26100.0' + outputs: + drift: ${{ steps.drift.outputs.drift }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # the release tags and origin/main select the tag to rebuild + persist-credentials: false + + - name: Setup xmake + uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1.2.5 + with: + xmake-version: '3.0.9' + + - name: Rebuild the latest release bridge + id: drift + run: | + $pins = @("--vs_toolset=$env:BRIDGE_VS_TOOLSET", "--vs_sdkver=$env:BRIDGE_VS_SDKVER") + ./eng/ci/health/Invoke-BridgeDriftCheck.ps1 -OutputDirectory artifacts/health/bridge-drift -XmakeConfigArgument $pins + if ($LASTEXITCODE -ne 0) { + throw "The release bridge rebuild failed with exit code $LASTEXITCODE." + } + + - name: Upload the drift report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: health-bridge-drift + path: artifacts/health/bridge-drift + if-no-files-found: warn + retention-days: 30 + + links: + name: External documentation links + runs-on: ubuntu-24.04 + timeout-minutes: 15 + outputs: + broken: ${{ steps.links.outputs.broken }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Reports broken links through its output and the job summary; it never fails on a link. + - name: Check external links + id: links + run: | + ./eng/ci/health/Test-ExternalLink.ps1 -OutputDirectory artifacts/health/links + if ($LASTEXITCODE -ne 0) { + throw "The link check failed with exit code $LASTEXITCODE." + } + + notify: + name: Report health + needs: [ audit, canary, test-repeat, bridge-drift, links ] + # Scheduled runs only: a manual dispatch is watched by the person who started it. + if: ${{ always() && github.event_name == 'schedule' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + issues: write # open or update the single scheduled health issue + steps: + - name: Checkout the health scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: eng/ci/health + persist-credentials: false + + # Only job ids, job results, two booleans and the run URL reach the issue (closed vocabularies). + - name: Open or update the health issue + env: + NEEDS: ${{ toJSON(needs) }} + DRIFT: ${{ needs.bridge-drift.outputs.drift }} + BROKEN_LINKS: ${{ needs.links.outputs.broken }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + ./eng/ci/health/Publish-HealthIssue.ps1 + if ($LASTEXITCODE -ne 0) { + throw "Reporting the scheduled health failed with exit code $LASTEXITCODE." + } diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 38292558..936fbc2c 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -36,6 +36,7 @@ + @@ -79,6 +80,18 @@ + + + + + + + + + + + + diff --git a/eng/ci/health/HealthCheck.psm1 b/eng/ci/health/HealthCheck.psm1 new file mode 100644 index 00000000..cc963f13 --- /dev/null +++ b/eng/ci/health/HealthCheck.psm1 @@ -0,0 +1,564 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Pure rules of the scheduled health workflow (.github/workflows/scheduled-health.yml). + +.DESCRIPTION + The health scripts next to this module do the side effects (git, the .NET CLI, xmake, HTTP, gh). Every decision they + take lives here instead, as a function of its arguments only: no file, network, git, process or environment + access. tests/CheatEngine.SDK.Repository.Tests/Governance (HealthCheckScriptTests) calls each function with its + vectors, so the decisions are tested offline while the scripts themselves are exercised by local runs. + + Functions: + Select-ReleaseTag newest v.. release tag + Get-BridgeDriftClassification Reproduced, ToolchainDrift or Failed for a rebuilt release bridge + Get-SdkChannel the release channel (major.minor) of an SDK version + Get-NewestSdkVersion latest-sdk of a release-metadata releases.json document + Get-UpdatedGlobalJson global.json text with only sdk.version replaced + Test-PackageReference whether an MSBuild file references a package + Get-TrxSummary the counters of a TRX test report + ConvertFrom-PackageListReport rows of a `package list --format json` report (vulnerable or deprecated) + Get-SolutionProjectPath the project paths a .slnx solution lists + Get-ExternalLinkTarget the external http(s) links of a Markdown text + Get-ExternalLinkVerdict Ok, Broken or Inconclusive for an HTTP status + Get-HealthIssueReport whether the run needs attention, and the issue body + Select-HealthIssue the open health issue with the exact title +#> + +Set-StrictMode -Version Latest + +$RegexTimeout = [TimeSpan]::FromSeconds(1) +$ReleaseTagPattern = '^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)$' +$SdkVersionPattern = '^(?[1-9]\d*)\.(?0|[1-9]\d*)\.(?[1-9]\d{2})$' +$Sha256Pattern = '^[0-9a-f]{64}$' +$HealthIssueTitle = 'Scheduled health check needs attention' +$JobResults = @('success', 'failure', 'cancelled', 'skipped') + +function Test-Match { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $InputText, + [Parameter(Mandatory)] [string] $Pattern + ) + + return [regex]::IsMatch($InputText, $Pattern, [System.Text.RegularExpressions.RegexOptions]::CultureInvariant, $RegexTimeout) +} + +<# +.SYNOPSIS + Returns the newest release tag: v.. without a prerelease or build suffix, compared as versions + (v1.10.0 is newer than v1.9.0). Returns $null when no tag qualifies. +#> +function Select-ReleaseTag { + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyCollection()] [Parameter(Mandatory)] [string[]] $Tag + ) + + $best = $null + $bestVersion = $null + foreach ($candidate in $Tag) { + $match = [regex]::Match($candidate, $ReleaseTagPattern, [System.Text.RegularExpressions.RegexOptions]::CultureInvariant, $RegexTimeout) + if (-not $match.Success) { + continue + } + + $version = [version]::new([int] $match.Groups['major'].Value, [int] $match.Groups['minor'].Value, [int] $match.Groups['patch'].Value) + if ($null -eq $bestVersion -or $version -gt $bestVersion) { + $best = $candidate + $bestVersion = $version + } + } + + return $best +} + +<# +.SYNOPSIS + Classifies a rebuild of a release tag's native bridge. + +.DESCRIPTION + Failed the two rebuilds from different directories differ (the build depends on its path), the rebuilt DLL + does not export the fingerprint of the tag's sources, the released package could not be read, or + the DLL committed at the tag is not the one the package shipped; + Reproduced today's toolchain rebuilds the released bytes exactly; + ToolchainDrift everything is consistent, but today's toolchain produces other bytes than the released DLL. + Every SHA-256 is compared in lowercase hex; a malformed value counts as a mismatch. +#> +function Get-BridgeDriftClassification { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [string] $RebuiltSha256, + [Parameter(Mandatory)] [string] $SecondRebuiltSha256, + [Parameter(Mandatory)] [string] $ExpectedFingerprint, + [AllowEmptyString()] [AllowNull()] [string] $ExportedFingerprint, + [Parameter(Mandatory)] [string] $CheckedInSha256, + [AllowEmptyString()] [AllowNull()] [string] $ReleasedSha256 + ) + + $reasons = [System.Collections.Generic.List[string]]::new() + foreach ($pair in @( + @('rebuilt bridge', $RebuiltSha256), @('second rebuilt bridge', $SecondRebuiltSha256), + @('bridge committed at the tag', $CheckedInSha256))) { + if (-not (Test-Match -InputText $pair[1] -Pattern $Sha256Pattern)) { + $reasons.Add("The SHA-256 of the $($pair[0]) is not 64 lowercase hex digits ('$($pair[1])').") + } + } + + if ($RebuiltSha256 -cne $SecondRebuiltSha256) { + $reasons.Add('The two rebuilds from different directories differ: the build depends on its path.') + } + + if ([string]::IsNullOrEmpty($ExportedFingerprint) -or $ExportedFingerprint -cne $ExpectedFingerprint) { + $reasons.Add("The rebuilt bridge exports the fingerprint '$ExportedFingerprint', not '$ExpectedFingerprint' of the tag's sources.") + } + + if ([string]::IsNullOrEmpty($ReleasedSha256)) { + $reasons.Add('The bridge of the released package could not be read.') + } + elseif (-not (Test-Match -InputText $ReleasedSha256 -Pattern $Sha256Pattern)) { + $reasons.Add("The SHA-256 of the released bridge is not 64 lowercase hex digits ('$ReleasedSha256').") + } + elseif ($CheckedInSha256 -cne $ReleasedSha256) { + $reasons.Add('The bridge committed at the tag is not the bridge the released package ships.') + } + + if ($reasons.Count -gt 0) { + return [pscustomobject]@{ Classification = 'Failed'; Reasons = [string[]] $reasons } + } + + if ($RebuiltSha256 -ceq $ReleasedSha256) { + return [pscustomobject]@{ + Classification = 'Reproduced' + Reasons = [string[]] @('Today''s toolchain rebuilds the released bridge byte for byte.') + } + } + + return [pscustomobject]@{ + Classification = 'ToolchainDrift' + Reasons = [string[]] @( + 'The sources match the release (same fingerprint), but today''s toolchain produces other bytes than the released bridge.') + } +} + +<# +.SYNOPSIS + Returns the release channel (major.minor) of a full .NET SDK version such as 10.0.401; throws on anything else. +#> +function Get-SdkChannel { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $Version + ) + + $match = [regex]::Match($Version, $SdkVersionPattern, [System.Text.RegularExpressions.RegexOptions]::CultureInvariant, $RegexTimeout) + if (-not $match.Success) { + throw "'$Version' is not a full .NET SDK version (major.minor.feature-band-and-patch, for example 10.0.401)." + } + + return "$($match.Groups['major'].Value).$($match.Groups['minor'].Value)" +} + +<# +.SYNOPSIS + Returns latest-sdk of a release-metadata releases.json document, checked to belong to the expected channel. + https://builds.dotnet.microsoft.com/dotnet/release-metadata//releases.json +#> +function Get-NewestSdkVersion { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $ReleasesJson, + [Parameter(Mandatory)] [string] $Channel + ) + + $metadata = $ReleasesJson | ConvertFrom-Json -AsHashtable + if ($metadata -isnot [System.Collections.IDictionary] -or -not $metadata.Contains('latest-sdk')) { + throw 'The release metadata has no latest-sdk property.' + } + + $latest = [string] $metadata['latest-sdk'] + if ((Get-SdkChannel -Version $latest) -cne $Channel) { + throw "The release metadata names $latest as the latest SDK, which is not on the $Channel channel." + } + + return $latest +} + +<# +.SYNOPSIS + Returns the global.json text with sdk.version set to -Version. Every other property (rollForward, allowPrerelease, + errorMessage, the test runner) is kept, in order: without the test section `dotnet test` would fall back to VSTest. +#> +function Get-UpdatedGlobalJson { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $Json, + [Parameter(Mandatory)] [string] $Version + ) + + [void] (Get-SdkChannel -Version $Version) + $document = $Json | ConvertFrom-Json -AsHashtable + if ($document -isnot [System.Collections.IDictionary] -or $document['sdk'] -isnot [System.Collections.IDictionary] -or + -not $document['sdk'].Contains('version')) { + throw 'global.json has no sdk.version property.' + } + + $document['sdk']['version'] = $Version + return ($document | ConvertTo-Json -Depth 10) +} + +<# +.SYNOPSIS + True when an MSBuild file's text has a PackageReference (or PackageVersion) to exactly -PackageId. +#> +function Test-PackageReference { + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $ProjectText, + [Parameter(Mandatory)] [string] $PackageId + ) + + $pattern = ' +function Get-TrxSummary { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [string] $Xml + ) + + $settings = [System.Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $document = [System.Xml.XmlDocument]::new() + $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($Xml), $settings) + try { + $document.Load($reader) + } + finally { + $reader.Dispose() + } + + $namespaces = [System.Xml.XmlNamespaceManager]::new($document.NameTable) + $namespaces.AddNamespace('t', 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010') + $summary = $document.SelectSingleNode('/t:TestRun/t:ResultSummary', $namespaces) + $counters = $document.SelectSingleNode('/t:TestRun/t:ResultSummary/t:Counters', $namespaces) + if ($null -eq $summary -or $null -eq $counters) { + throw 'The TRX report has no ResultSummary/Counters element.' + } + + $read = { + param([string] $name) + $value = $counters.GetAttribute($name) + if ([string]::IsNullOrEmpty($value)) { return 0 } + return [int]::Parse($value, [System.Globalization.CultureInfo]::InvariantCulture) + } + + return [pscustomobject]@{ + Outcome = $summary.GetAttribute('outcome') + Total = & $read 'total' + Executed = & $read 'executed' + Passed = & $read 'passed' + Failed = & $read 'failed' + NotExecuted = & $read 'notExecuted' + } +} + +# A project path of a package list report, relative to the repository root with forward slashes; the file name alone +# when the path is outside the root (a report never carries a machine path into a summary). +function ConvertTo-ReportProjectPath { + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $Path, + [AllowEmptyString()] [string] $RepositoryRoot = '' + ) + + $normalized = $Path.Replace('\', '/') + $root = $RepositoryRoot.Replace('\', '/').TrimEnd('/') + if ($root -and $normalized.StartsWith("$root/", [StringComparison]::OrdinalIgnoreCase)) { + return $normalized.Substring($root.Length + 1) + } + + return [System.IO.Path]::GetFileName($normalized) +} + +<# +.SYNOPSIS + Flattens a `dotnet package list --format json` report (output version 1) run with --vulnerable or --deprecated into + one row per project, framework and package. Detail holds the severities and advisory URLs, or the deprecation reasons + and the alternative package. A report "problem" becomes a row with Package '(problem)'. +#> +function ConvertFrom-PackageListReport { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [string] $Json, + [Parameter(Mandatory)] [ValidateSet('Vulnerable', 'Deprecated')] [string] $Kind, + [string] $RepositoryRoot = '' + ) + + $report = $Json | ConvertFrom-Json -AsHashtable + if ($report -isnot [System.Collections.IDictionary] -or [int] $report['version'] -ne 1) { + throw 'Expected a package list JSON report with "version": 1.' + } + + foreach ($problem in @($report['problems'] | Where-Object { $_ })) { + [pscustomobject]@{ + Project = ConvertTo-ReportProjectPath -Path ([string] $problem['project']) -RepositoryRoot $RepositoryRoot + Framework = '' + Package = '(problem)' + Resolved = '' + Transitive = $false + Detail = "$($problem['level']): $($problem['text'])" + } + } + + foreach ($project in @($report['projects'] | Where-Object { $_ })) { + foreach ($framework in @($project['frameworks'] | Where-Object { $_ })) { + foreach ($section in @('topLevelPackages', 'transitivePackages')) { + foreach ($package in @($framework[$section] | Where-Object { $_ })) { + if ($Kind -ceq 'Vulnerable') { + $details = @($package['vulnerabilities'] | Where-Object { $_ } | + ForEach-Object { "$($_['severity']) $($_['advisoryurl'])" }) + } + else { + $details = @(@($package['deprecationReasons'] | Where-Object { $_ }) -join ', ') + if ($package.Contains('alternativePackage') -and $package['alternativePackage']) { + $details += "use $($package['alternativePackage']['id']) $($package['alternativePackage']['versionRange'])" + } + } + + [pscustomobject]@{ + Project = ConvertTo-ReportProjectPath -Path ([string] $project['path']) -RepositoryRoot $RepositoryRoot + Framework = [string] $framework['framework'] + Package = [string] $package['id'] + Resolved = [string] $package['resolvedVersion'] + Transitive = $section -ceq 'transitivePackages' + Detail = (@($details | Where-Object { $_ }) -join '; ') + } + } + } + } + } +} + +<# +.SYNOPSIS + Returns the project paths (forward slashes, as written) of a .slnx solution document. +#> +function Get-SolutionProjectPath { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $SolutionXml + ) + + $settings = [System.Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $document = [System.Xml.XmlDocument]::new() + $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($SolutionXml), $settings) + try { + $document.Load($reader) + } + finally { + $reader.Dispose() + } + + foreach ($project in $document.SelectNodes('//Project')) { + $project.GetAttribute('Path').Replace('\', '/') + } +} + +<# +.SYNOPSIS + Returns the distinct external http(s) links of a Markdown text, in order of first appearance. Fenced code blocks + and code spans are skipped (commands and templates, not links), and so are links back into this repository's main branch (the + repository tests check them offline), local hosts, example domains and templated URLs (<...>, {...}, $...). +#> +function Get-ExternalLinkTarget { + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] [Parameter(Mandatory)] [string] $Markdown, + [Parameter(Mandatory)] [string] $RepositorySlug + ) + + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $selfPrefix = "https://github.com/$RepositorySlug/" + $inFence = $false + foreach ($line in ($Markdown -split "\r?\n")) { + if (Test-Match -InputText $line -Pattern '^\s{0,3}(```|~~~)') { + $inFence = -not $inFence + continue + } + + if ($inFence) { + continue + } + + # Code spans hold commands and templates, not links. + $prose = [regex]::Replace($line, '(`+).+?\1', ' ', [System.Text.RegularExpressions.RegexOptions]::CultureInvariant, $RegexTimeout) + $candidates = [regex]::Matches($prose, 'https?://[^\s<>()\[\]"''`|]+', [System.Text.RegularExpressions.RegexOptions]::CultureInvariant, $RegexTimeout) + foreach ($match in $candidates) { + # A URL that continues with a placeholder (https://host//...) is a template. + $next = $match.Index + $match.Length + if ($next -lt $prose.Length -and $prose[$next] -in @([char] '<', [char] '{')) { + continue + } + + $url = $match.Value.TrimEnd('.', ',', ';', ':', '!', '?', '*', '_') + $uri = $null + if (-not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref] $uri)) { + continue + } + + $isSelf = $url.StartsWith($selfPrefix + 'blob/main/', [StringComparison]::OrdinalIgnoreCase) -or + $url.StartsWith($selfPrefix + 'tree/main/', [StringComparison]::OrdinalIgnoreCase) + $isLocal = $uri.Host -in @('localhost', '127.0.0.1', '[::1]') -or + $uri.Host -match '(^|\.)example\.(com|org|net)$' + $isTemplate = $url.IndexOfAny([char[]] '{}$') -ge 0 + if ($isSelf -or $isLocal -or $isTemplate) { + continue + } + + if ($seen.Add($url)) { + $url + } + } + } +} + +<# +.SYNOPSIS + Classifies the answer to a link check: Ok (2xx, 3xx), Broken (404 Not Found, 410 Gone), Inconclusive (anything + else, including rate limiting, server errors and a request that got no answer, StatusCode 0). +#> +function Get-ExternalLinkVerdict { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [int] $StatusCode + ) + + if ($StatusCode -ge 200 -and $StatusCode -lt 400) { + return 'Ok' + } + + if ($StatusCode -in @(404, 410)) { + return 'Broken' + } + + return 'Inconclusive' +} + +<# +.SYNOPSIS + Decides whether a scheduled health run needs attention and builds the issue body. + +.DESCRIPTION + -NeedsJson is `toJSON(needs)` of the notify job. A run needs attention when a job did not succeed, when the bridge + check reported drift, or when broken links were found. Only values from closed vocabularies reach the body: job ids + that are not plain identifiers and results outside success/failure/cancelled/skipped are rendered as '(unexpected)', + and a run URL that is not a GitHub Actions run URL is left out. +#> +function Get-HealthIssueReport { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] [string] $NeedsJson, + [AllowEmptyString()] [string] $Drift = '', + [AllowEmptyString()] [string] $BrokenLinks = '', + [AllowEmptyString()] [string] $RunUrl = '' + ) + + $needs = $NeedsJson | ConvertFrom-Json -AsHashtable + if ($needs -isnot [System.Collections.IDictionary] -or $needs.Count -eq 0) { + throw 'NEEDS must be the non-empty toJSON(needs) object of the notify job.' + } + + $rows = [System.Collections.Generic.List[string]]::new() + $attention = $false + foreach ($job in @($needs.Keys | Sort-Object)) { + $entry = $needs[$job] + $result = if ($entry -is [System.Collections.IDictionary]) { [string] $entry['result'] } else { '' } + $safeJob = if (Test-Match -InputText ([string] $job) -Pattern '^[A-Za-z0-9_-]{1,64}$') { [string] $job } else { '(unexpected)' } + $safeResult = if ($result -cin $JobResults) { $result } else { '(unexpected)' } + if ($safeResult -cne 'success') { + $attention = $true + } + + $rows.Add("| $safeJob | $safeResult |") + } + + $findings = [System.Collections.Generic.List[string]]::new() + if ($Drift -ceq 'true') { + $attention = $true + $findings.Add('- The release bridge rebuild does not reproduce the released DLL (see the `health-bridge-drift` artifact).') + } + + if ($BrokenLinks -ceq 'true') { + $attention = $true + $findings.Add('- External documentation links are broken (see the job summary of `links`).') + } + + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('The weekly scheduled health workflow found a problem.') + $lines.Add('') + if (Test-Match -InputText $RunUrl -Pattern '^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/actions/runs/\d+$') { + $lines.Add("Run: $RunUrl") + $lines.Add('') + } + + $lines.Add('| Job | Result |') + $lines.Add('| --- | --- |') + $lines.AddRange($rows) + if ($findings.Count -gt 0) { + $lines.Add('') + $lines.AddRange($findings) + } + + $lines.Add('') + $lines.Add('How to act on each job: eng/ci/health/README.md.') + return [pscustomobject]@{ + NeedsAttention = $attention + Title = $HealthIssueTitle + Body = ($lines -join "`n") + } +} + +<# +.SYNOPSIS + Returns the number of the open issue whose title is exactly the health issue title, the lowest one when several + exist, or $null. -IssueListJson is the output of `gh issue list --json number,title`. +#> +function Select-HealthIssue { + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] [string] $IssueListJson + ) + + $issues = @($IssueListJson | ConvertFrom-Json -AsHashtable) + $numbers = @($issues | Where-Object { $_ -is [System.Collections.IDictionary] -and ([string] $_['title']) -ceq $HealthIssueTitle } | + ForEach-Object { [int] $_['number'] } | Sort-Object) + if ($numbers.Count -eq 0) { + return $null + } + + return $numbers[0] +} + +Export-ModuleMember -Function Select-ReleaseTag, Get-BridgeDriftClassification, Get-SdkChannel, Get-NewestSdkVersion, +Get-UpdatedGlobalJson, Test-PackageReference, Get-TrxSummary, ConvertFrom-PackageListReport, Get-SolutionProjectPath, +Get-ExternalLinkTarget, Get-ExternalLinkVerdict, Get-HealthIssueReport, Select-HealthIssue diff --git a/eng/ci/health/HealthCommand.psm1 b/eng/ci/health/HealthCommand.psm1 new file mode 100644 index 00000000..13a02722 --- /dev/null +++ b/eng/ci/health/HealthCommand.psm1 @@ -0,0 +1,230 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Side-effect helpers shared by the scheduled health scripts: native commands with exit-code checks, git queries, + the project inventory, and the GitHub Actions output and summary files. + +.DESCRIPTION + Decisions do not belong here: they live in HealthCheck.psm1, which is pure and tested offline. Every native command + goes through Invoke-NativeCommand or Get-NativeCommandOutput, which check $LASTEXITCODE (shared.md 7). No helper + writes to GITHUB_ENV (zizmor github-env); step outputs go to GITHUB_OUTPUT. +#> + +Set-StrictMode -Version Latest + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCheck.psm1') + +<# +.SYNOPSIS + The repository root: two folders above eng/ci/health. +#> +function Get-HealthRepositoryRoot { + [CmdletBinding()] + [OutputType([string])] + param() + + return [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath '../../..')) +} + +<# +.SYNOPSIS + Runs a native command with its output on the host and throws when it exits with another code than 0, unless + -AllowFailure is set, in which case the exit code is returned. +#> +function Invoke-NativeCommand { + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] [string] $FilePath, + [AllowEmptyCollection()] [Parameter(Mandatory)] [string[]] $ArgumentList, + [Parameter(Mandatory)] [string] $Description, + [switch] $AllowFailure + ) + + Write-Host "> $([System.IO.Path]::GetFileName($FilePath)) $($ArgumentList -join ' ')" + & $FilePath @ArgumentList | Out-Host + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -and -not $AllowFailure) { + throw "$Description failed with exit code $exitCode." + } + + if ($AllowFailure) { + return $exitCode + } +} + +<# +.SYNOPSIS + Runs a native command and returns its standard output lines; throws on a non-zero exit code. +#> +function Get-NativeCommandOutput { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $FilePath, + [AllowEmptyCollection()] [Parameter(Mandatory)] [string[]] $ArgumentList, + [Parameter(Mandatory)] [string] $Description + ) + + $output = & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "$Description failed with exit code $LASTEXITCODE." + } + + return @($output | ForEach-Object { [string] $_ }) +} + +<# +.SYNOPSIS + Runs git in the repository and returns its non-empty output lines; throws on a non-zero exit code. +#> +function Get-GitOutput { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string[]] $ArgumentList + ) + + $root = Get-HealthRepositoryRoot + return @(Get-NativeCommandOutput -FilePath 'git' -ArgumentList (@('-C', $root) + $ArgumentList) ` + -Description "git $($ArgumentList -join ' ')" | Where-Object { $_ }) +} + +<# +.SYNOPSIS + Writes the raw bytes of a git object (for example v1.0.0:native/x.c) to a file: line endings and encodings are + kept exactly as committed, which a PowerShell pipeline would not guarantee. +#> +function Save-GitBlob { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Object, + [Parameter(Mandatory)] [string] $Destination + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new('git') + foreach ($argument in @('-C', (Get-HealthRepositoryRoot), 'cat-file', 'blob', $Object)) { + $startInfo.ArgumentList.Add($argument) + } + + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.UseShellExecute = $false + $process = [System.Diagnostics.Process]::Start($startInfo) + try { + $errorText = $process.StandardError.ReadToEndAsync() + $file = [System.IO.File]::Create($Destination) + try { + $process.StandardOutput.BaseStream.CopyTo($file) + } + finally { + $file.Dispose() + } + + $process.WaitForExit() + if ($process.ExitCode -ne 0) { + throw "git cat-file blob $Object failed with exit code $($process.ExitCode): $($errorText.Result.Trim())" + } + } + finally { + $process.Dispose() + } +} + +<# +.SYNOPSIS + The projects to restore: the solution first, then every tracked project the solution does not list (the same + inventory as eng/Update-LockFiles.ps1), as repository-relative paths. +#> +function Get-RepositoryRestoreTarget { + [CmdletBinding()] + [OutputType([string])] + param( + [string] $SolutionFile = 'CheatEngine.SDK.slnx' + ) + + $root = Get-HealthRepositoryRoot + $inSolution = @(Get-SolutionProjectPath -SolutionXml (Get-Content -Raw -LiteralPath (Join-Path -Path $root -ChildPath $SolutionFile))) + $outside = @(Get-GitOutput -ArgumentList @('ls-files', '--', '*.csproj') | Where-Object { $_ -notin $inSolution } | Sort-Object) + return @($SolutionFile) + $outside +} + +<# +.SYNOPSIS + Appends name=value lines to the GitHub Actions step output file when it exists. Values must be single-line. +#> +function Write-HealthOutput { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.IDictionary] $Value + ) + + if (-not $env:GITHUB_OUTPUT) { + return + } + + $lines = foreach ($name in $Value.Keys) { + $text = [string] $Value[$name] + if ($text.IndexOfAny([char[]] "`r`n") -ge 0) { + throw "The step output '$name' must be a single line." + } + + "$name=$text" + } + + $lines | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 +} + +<# +.SYNOPSIS + Prints Markdown lines and appends them to the GitHub Actions job summary when it exists. +#> +function Write-HealthSummary { + [CmdletBinding()] + param( + [AllowEmptyCollection()] [AllowEmptyString()] [Parameter(Mandatory)] [string[]] $Line + ) + + $Line | ForEach-Object { Write-Host $_ } + if ($env:GITHUB_STEP_SUMMARY) { + $Line | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } +} + +<# +.SYNOPSIS + Escapes a value for a Markdown table cell. +#> +function ConvertTo-HealthTableCell { + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] [AllowNull()] [string] $Text + ) + + return ([string] $Text).Replace('|', '\|').Replace("`r", ' ').Replace("`n", ' ') +} + +<# +.SYNOPSIS + Resolves a path against the repository root unless it is already absolute, and creates the directory. +#> +function New-HealthOutputDirectory { + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param( + [Parameter(Mandatory)] [string] $Path + ) + + $full = if ([System.IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path -Path (Get-HealthRepositoryRoot) -ChildPath $Path } + $full = [System.IO.Path]::GetFullPath($full) + if ($PSCmdlet.ShouldProcess($full, 'Create directory')) { + New-Item -ItemType Directory -Force -Path $full | Out-Null + } + + return $full +} + +Export-ModuleMember -Function Get-HealthRepositoryRoot, Invoke-NativeCommand, Get-NativeCommandOutput, Get-GitOutput, +Save-GitBlob, Get-RepositoryRestoreTarget, Write-HealthOutput, Write-HealthSummary, ConvertTo-HealthTableCell, +New-HealthOutputDirectory diff --git a/eng/ci/health/Invoke-BridgeDriftCheck.ps1 b/eng/ci/health/Invoke-BridgeDriftCheck.ps1 new file mode 100644 index 00000000..cf317e39 --- /dev/null +++ b/eng/ci/health/Invoke-BridgeDriftCheck.ps1 @@ -0,0 +1,371 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Rebuilds the native protection bridge of the latest release tag with today's toolchain and compares it with the + DLL that release shipped (audit register A21-25). + +.DESCRIPTION + A release is trusted because its artifacts can be rebuilt from the tagged sources (audit ch.21, "exit criteria"). The + bridge is the one native binary of the package, so this check rebuilds it on a schedule and tells whether today's + toolchain still produces the released bytes: + + 1. Select the tag: -Tag, or the newest v.. tag reachable from origin/main (else main) whose tree + contains native/cheatengine-sdk-lua-bridge/xmake.lua (the v0.x tags of the former CESDK package have no bridge). + 2. Extract the two build inputs of that tag, cheatengine_sdk_lua_bridge.c and xmake.lua, as raw committed bytes (LF, + their SHA-256 is the source fingerprint), into two directories of different depth under a temporary folder + outside the checkout. Nothing is ever written inside the checkout except the report under -OutputDirectory. + 3. Build each copy with xmake (Windows x64 release, --ccache=n so a cache hit cannot fake reproducibility, plus + -XmakeConfigArgument: the MSVC toolset and Windows SDK pins of the ci.yml native job). Every xmake path is + relative to the current directory: xmake 3.0.9 mis-parses an absolute Windows -o path. + 4. Compare the two builds (path independence), read the exported cheatengine_sdk_lua_bridge_source_fingerprint of + the rebuild, and hash the DLL committed at the tag and build/native/cheatengine-sdk-lua-bridge.dll inside the + released nupkg on nuget.org. + 5. Classify with Get-BridgeDriftClassification (HealthCheck.psm1): Reproduced, ToolchainDrift (a ::warning::, not a + failure) or Failed (throws). Write bridge-drift.json, the rebuilt DLL and the job summary to -OutputDirectory, and + drift=true|false to GITHUB_OUTPUT. + + bridge-drift.json is a report, not a contract document. + +.PARAMETER Tag + The release tag to rebuild (for example v1.0.0). Defaults to the newest release tag that ships the bridge. + +.PARAMETER XmakePath + The xmake executable. Defaults to xmake on PATH. + +.PARAMETER XmakeConfigArgument + Extra `xmake f` arguments, for example --vs_toolset=14.44 and --vs_sdkver=10.0.26100.0. + +.PARAMETER OutputDirectory + Where bridge-drift.json, summary.md and the rebuilt DLL are written. + +.EXAMPLE + ./eng/ci/health/Invoke-BridgeDriftCheck.ps1 -Tag v1.0.0 -OutputDirectory "$env:TEMP/drift" +#> +[CmdletBinding()] +param( + [string] $Tag, + [string] $XmakePath = 'xmake', + [string[]] $XmakeConfigArgument = @(), + [string] $OutputDirectory = 'artifacts/health/bridge-drift' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCheck.psm1') -Force +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCommand.psm1') -Force + +if (-not $IsWindows) { + throw 'The bridge is a Windows x64 DLL built with MSVC: run the drift check on Windows.' +} + +Add-Type -AssemblyName System.IO.Compression.ZipFile + +$bridgeDirectory = 'native/cheatengine-sdk-lua-bridge' +$projectName = 'cheatengine-sdk-lua-bridge' +$bridgeFile = 'cheatengine-sdk-lua-bridge.dll' +$buildInputs = @('cheatengine_sdk_lua_bridge.c', 'xmake.lua') +$checkedInPath = "$bridgeDirectory/runtimes/win-x64/native/$bridgeFile" +$packageEntry = "build/native/$bridgeFile" +$fingerprintExport = 'cheatengine_sdk_lua_bridge_source_fingerprint' + +$root = Get-HealthRepositoryRoot +$output = New-HealthOutputDirectory -Path $OutputDirectory + +function Test-GitObject { + param( + [Parameter(Mandatory)] [string] $Object + ) + + # A query whose non-zero exit code is an answer (the object does not exist), not a failure. + & git -C $root cat-file -e $Object 2>$null + return $LASTEXITCODE -eq 0 +} + +function Get-Sha256 { + param( + [Parameter(Mandatory)] [string] $Path + ) + + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Get-ExportedFingerprint { + param( + [Parameter(Mandatory)] [string] $Path + ) + + $module = [System.Runtime.InteropServices.NativeLibrary]::Load($Path) + try { + $address = [System.Runtime.InteropServices.NativeLibrary]::GetExport($module, $fingerprintExport) + return [System.Runtime.InteropServices.Marshal]::PtrToStringAnsi($address) + } + finally { + [System.Runtime.InteropServices.NativeLibrary]::Free($module) + } +} + +# The toolchain xmake resolved, read from its toolchain cache (format internal to the pinned xmake 3.0.9), best effort: +# the classification depends on bytes, the toolchain facts only explain drift. +function Get-ResolvedToolchain { + param( + [Parameter(Mandatory)] [string] $BuildDirectory + ) + + $facts = [ordered]@{ msvcToolset = $null; windowsSdk = $null; clVersion = $null } + $cache = @( + (Join-Path -Path $BuildDirectory -ChildPath '.xmake/windows/x64/cache/toolchain'), + (Join-Path -Path $BuildDirectory -ChildPath "$projectName/.xmake/windows/x64/cache/toolchain") + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $cache) { + Write-Host '::notice title=Bridge drift::xmake wrote no toolchain cache; the MSVC and Windows SDK versions are not recorded.' + return $facts + } + + $text = Get-Content -Raw -LiteralPath $cache + $read = { + param([string] $name) + $values = @([regex]::Matches($text, "\b$name\s*=\s*(?:`"(?[^`"]+)`"|\[\[(?[^\]]+)\]\])") | + ForEach-Object { $_.Groups['value'].Value.Trim().TrimEnd('\') } | Sort-Object -Unique) + if ($values.Count -eq 1) { return $values[0] } + return $null + } + + $facts.msvcToolset = & $read 'VCToolsVersion' + $facts.windowsSdk = & $read 'WindowsSDKVersion' + $toolsDirectory = & $read 'VCToolsInstallDir' + $compiler = if ($toolsDirectory) { Join-Path -Path $toolsDirectory -ChildPath 'bin/HostX64/x64/cl.exe' } else { $null } + if ($compiler -and (Test-Path -LiteralPath $compiler -PathType Leaf)) { + # Without arguments cl.exe prints its banner and usage and exits 0; VSLANG=1033 keeps the banner in English. + $previousLanguage = $env:VSLANG + $env:VSLANG = '1033' + try { + $banner = @(& $compiler 2>&1 | ForEach-Object { "$_" }) + if ($LASTEXITCODE -ne 0) { + throw "cl.exe exited with code $LASTEXITCODE while printing its banner." + } + } + finally { + $env:VSLANG = $previousLanguage + } + + foreach ($line in $banner) { + if ($line -match 'C/C\+\+.*?\b(?\d+\.\d+\.\d+(?:\.\d+)?)\b') { + $facts.clVersion = $Matches['version'] + break + } + } + } + + return $facts +} + +function Invoke-BridgeBuild { + param( + [Parameter(Mandatory)] [string] $BuildDirectory, + [Parameter(Mandatory)] [string] $Label + ) + + Push-Location -LiteralPath $BuildDirectory + try { + Invoke-NativeCommand -FilePath $XmakePath -Description "xmake configuration of the $Label rebuild" -ArgumentList (@( + 'f', '-P', $projectName, '-o', 'out', '-p', 'windows', '-a', 'x64', '-m', 'release', '-y', '--ccache=n') + $XmakeConfigArgument) + Invoke-NativeCommand -FilePath $XmakePath -ArgumentList @('-P', $projectName, '-y') -Description "xmake build of the $Label rebuild" + } + finally { + Pop-Location + } + + $dll = Join-Path -Path $BuildDirectory -ChildPath "out/$bridgeFile" + if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) { + throw "xmake did not produce '$dll' for the $Label rebuild." + } + + return $dll +} + +# 1. The tag. +if (-not $Tag) { + $base = $null + foreach ($candidate in @('refs/remotes/origin/main', 'refs/heads/main')) { + & git -C $root rev-parse --verify --quiet $candidate *> $null + if ($LASTEXITCODE -eq 0) { + $base = $candidate + break + } + } + + if (-not $base) { + throw 'Neither origin/main nor main exists: check out with fetch-depth: 0, or pass -Tag.' + } + + $candidates = @(Get-GitOutput -ArgumentList @('tag', '--merged', $base, '--list', 'v*') | + Where-Object { Test-GitObject -Object "refs/tags/${_}:$bridgeDirectory/xmake.lua" }) + $Tag = Select-ReleaseTag -Tag $candidates + if (-not $Tag) { + throw "No release tag reachable from $base contains $bridgeDirectory/xmake.lua." + } +} + +$tagMatch = [regex]::Match($Tag, '^v(?\d+\.\d+\.\d+)$') +if (-not $tagMatch.Success) { + throw "'$Tag' is not a release tag of the form v..." +} + +$packageVersion = $tagMatch.Groups['version'].Value +$commit = @(Get-GitOutput -ArgumentList @('rev-parse', "refs/tags/$Tag^{commit}"))[0] +$runner = [ordered]@{ + imageOs = if ($env:ImageOS) { $env:ImageOS } else { 'local' } + imageVersion = if ($env:ImageVersion) { $env:ImageVersion } else { 'local' } +} +$xmakeVersion = $null +$xmakeBanner = @(Get-NativeCommandOutput -FilePath $XmakePath -ArgumentList @('--version') -Description 'xmake --version' | + ForEach-Object { $_ -replace '\x1b\[[0-9;]*m', '' }) +foreach ($line in $xmakeBanner) { + if ($line -match '\bxmake v(?\d+\.\d+\.\d+)') { + $xmakeVersion = $Matches['version'] + break + } +} + +# 2-4. Rebuild twice outside the checkout, then measure. +$temporaryRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } +$work = [System.IO.Path]::GetFullPath((Join-Path -Path $temporaryRoot -ChildPath "bridge-drift-$([guid]::NewGuid().ToString('N'))")) +if ("$work$([System.IO.Path]::DirectorySeparatorChar)".StartsWith("$root$([System.IO.Path]::DirectorySeparatorChar)", [StringComparison]::OrdinalIgnoreCase)) { + throw "The work directory '$work' must be outside the checkout '$root'." +} + +$report = [ordered]@{ + tag = $Tag + commit = $commit + packageVersion = $packageVersion + sourceFingerprint = [ordered]@{ expected = $null; exported = $null } + sha256 = [ordered]@{ rebuilt = $null; rebuiltSecondDirectory = $null; committedAtTag = $null; releasedPackage = $null } + toolchain = [ordered]@{ xmake = $xmakeVersion; xmakeConfigArguments = @($XmakeConfigArgument); msvcToolset = $null; windowsSdk = $null; clVersion = $null } + runner = $runner + classification = $null + reasons = @() + createdUtc = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture) +} +$buildError = $null +try { + $buildDirectories = @((Join-Path -Path $work -ChildPath 'a'), (Join-Path -Path $work -ChildPath 'path-check/deeper')) + $inputHashes = @() + foreach ($buildDirectory in $buildDirectories) { + $project = Join-Path -Path $buildDirectory -ChildPath $projectName + New-Item -ItemType Directory -Force -Path $project | Out-Null + foreach ($inputFile in $buildInputs) { + Save-GitBlob -Object "refs/tags/${Tag}:$bridgeDirectory/$inputFile" -Destination (Join-Path -Path $project -ChildPath $inputFile) + } + } + + foreach ($inputFile in $buildInputs) { + $inputHashes += Get-Sha256 -Path (Join-Path -Path $buildDirectories[0] -ChildPath "$projectName/$inputFile") + } + + $report.sourceFingerprint.expected = $inputHashes -join ':' + $committedAtTag = Join-Path -Path $work -ChildPath "committed-$bridgeFile" + Save-GitBlob -Object "refs/tags/${Tag}:$checkedInPath" -Destination $committedAtTag + $report.sha256.committedAtTag = Get-Sha256 -Path $committedAtTag + + $package = Join-Path -Path $work -ChildPath "cheatengine.sdk.$packageVersion.nupkg" + $packageUrl = "https://api.nuget.org/v3-flatcontainer/cheatengine.sdk/$packageVersion/cheatengine.sdk.$packageVersion.nupkg" + Write-Host "Downloading $packageUrl." + $response = Invoke-WebRequest -Uri $packageUrl -OutFile $package -PassThru -SkipHttpErrorCheck -MaximumRetryCount 3 -RetryIntervalSec 5 -UseBasicParsing + if ($response.StatusCode -eq 200) { + $archive = [System.IO.Compression.ZipFile]::OpenRead($package) + try { + $entry = @($archive.Entries | Where-Object { $_.FullName -ceq $packageEntry }) | Select-Object -First 1 + if ($entry) { + $stream = $entry.Open() + try { + $report.sha256.releasedPackage = [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($stream)).ToLowerInvariant() + } + finally { + $stream.Dispose() + } + } + else { + Write-Host "::warning title=Bridge drift::CheatEngine.SDK $packageVersion has no $packageEntry." + } + } + finally { + $archive.Dispose() + } + } + elseif ($response.StatusCode -eq 404) { + Write-Host "::warning title=Bridge drift::CheatEngine.SDK $packageVersion is not on nuget.org ($packageUrl returned 404)." + } + else { + throw "Downloading $packageUrl returned HTTP $($response.StatusCode)." + } + + $first = Invoke-BridgeBuild -BuildDirectory $buildDirectories[0] -Label 'first' + $toolchain = Get-ResolvedToolchain -BuildDirectory $buildDirectories[0] + foreach ($name in @($toolchain.Keys)) { + $report.toolchain[$name] = $toolchain[$name] + } + + $second = Invoke-BridgeBuild -BuildDirectory $buildDirectories[1] -Label 'second (other directory)' + $report.sha256.rebuilt = Get-Sha256 -Path $first + $report.sha256.rebuiltSecondDirectory = Get-Sha256 -Path $second + $report.sourceFingerprint.exported = Get-ExportedFingerprint -Path $first + Copy-Item -LiteralPath $first -Destination (Join-Path -Path $output -ChildPath $bridgeFile) -Force +} +catch { + $buildError = $_.Exception.Message +} +finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} + +# 5. Classify and report. +if ($buildError) { + $report.classification = 'Failed' + $report.reasons = @("The rebuild could not complete: $buildError") +} +else { + $verdict = Get-BridgeDriftClassification -RebuiltSha256 $report.sha256.rebuilt -SecondRebuiltSha256 $report.sha256.rebuiltSecondDirectory ` + -ExpectedFingerprint $report.sourceFingerprint.expected -ExportedFingerprint $report.sourceFingerprint.exported ` + -CheckedInSha256 $report.sha256.committedAtTag -ReleasedSha256 $report.sha256.releasedPackage + $report.classification = $verdict.Classification + $report.reasons = @($verdict.Reasons) +} + +[System.IO.File]::WriteAllText((Join-Path -Path $output -ChildPath 'bridge-drift.json'), ($report | ConvertTo-Json -Depth 5), + [System.Text.UTF8Encoding]::new($false)) + +$cell = { param($value) if ($null -eq $value -or "$value" -eq '') { '(not available)' } else { "``$(ConvertTo-HealthTableCell -Text ([string] $value))``" } } +$summary = @( + '## Release bridge rebuild', + '', + '| Fact | Value |', + '| --- | --- |', + "| Tag | $(& $cell $Tag) at $(& $cell $commit) |", + "| Source fingerprint of the tag | $(& $cell $report.sourceFingerprint.expected) |", + "| Fingerprint exported by the rebuild | $(& $cell $report.sourceFingerprint.exported) |", + "| Rebuilt SHA-256 | $(& $cell $report.sha256.rebuilt) |", + "| Rebuilt SHA-256, second directory | $(& $cell $report.sha256.rebuiltSecondDirectory) |", + "| Committed at the tag | $(& $cell $report.sha256.committedAtTag) |", + "| Released package (nuget.org) | $(& $cell $report.sha256.releasedPackage) |", + "| xmake | $(& $cell $report.toolchain.xmake) |", + "| xmake configuration arguments | $(& $cell ($XmakeConfigArgument -join ' ')) |", + "| MSVC toolset / cl.exe | $(& $cell $report.toolchain.msvcToolset) / $(& $cell $report.toolchain.clVersion) |", + "| Windows SDK | $(& $cell $report.toolchain.windowsSdk) |", + "| Runner image | $(& $cell "$($runner.imageOs) $($runner.imageVersion)") |", + "| Classification | **$($report.classification)** |", + '' +) +$summary += @($report.reasons | ForEach-Object { "- $(ConvertTo-HealthTableCell -Text $_)" }) +Write-HealthSummary -Line $summary +[System.IO.File]::WriteAllLines((Join-Path -Path $output -ChildPath 'summary.md'), [string[]] $summary, [System.Text.UTF8Encoding]::new($false)) +Write-HealthOutput -Value ([ordered]@{ drift = $(if ($report.classification -ceq 'Reproduced') { 'false' } else { 'true' }) }) + +switch ($report.classification) { + 'ToolchainDrift' { + Write-Host "::warning title=Bridge drift::Today's toolchain does not rebuild the $Tag bridge byte for byte (sources unchanged). See the health-bridge-drift artifact." + } + 'Failed' { + throw "The $Tag bridge check failed: $($report.reasons -join ' ')" + } +} diff --git a/eng/ci/health/Invoke-SdkCanary.ps1 b/eng/ci/health/Invoke-SdkCanary.ps1 new file mode 100644 index 00000000..3c71268b --- /dev/null +++ b/eng/ci/health/Invoke-SdkCanary.ps1 @@ -0,0 +1,172 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Second stage of the newest-SDK canary: regenerates the lock files with the SDK global.json now selects, then + builds, packs and tests the solution with it, and keeps the lock-file patch. + +.DESCRIPTION + Runs after Set-CanarySdkVersion.ps1 and the composite setup action installed the SDK that global.json names: + + 1. Check that the active SDK is the one global.json selects (rollForward: disable), and read the committed pin from + HEAD's global.json. + 2. Regenerate every lock file with ./eng/Update-LockFiles.ps1, the repository's single regeneration sequence (it + restores with --force-evaluate, never together with --locked-mode, NU1005), and write + `git diff -- '*packages.lock.json'` to lock-files.patch. An empty patch is a valid result. With the pinned SDK + the patch must be empty: otherwise the committed lock files are stale, and the canary fails. + 3. Build the solution in Release, pack src/CheatEngine.SDK, and, unless -SkipTests, run every test module in + Release with CESDK_PACKAGED_UMBRELLA_NUPKG set to the packed file, so the packaging tests consume exactly that + package (shared-contracts 1.8), and --fail-skips on. + 4. Write summary.md (pinned and canary SDK, changed lock files, build, pack and test totals) to the output folder + and the job summary. + + Only a failure of the build, the pack or a test fails the canary; a non-empty patch with a newer SDK is the expected + result and is attached to the health-sdk-canary artifact. How to apply it: eng/ci/health/README.md. + +.PARAMETER OutputDirectory + Where lock-files.patch, summary.md, the package and the TRX reports are written. + +.PARAMETER SkipTests + Stop after the pack (local rehearsals: the packaging tests take minutes). + +.EXAMPLE + ./eng/ci/health/Invoke-SdkCanary.ps1 -SkipTests -OutputDirectory "$env:TEMP/canary" +#> +[CmdletBinding()] +param( + [string] $OutputDirectory = 'artifacts/health/sdk-canary', + [switch] $SkipTests +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCheck.psm1') -Force +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCommand.psm1') -Force + +$root = Get-HealthRepositoryRoot +$output = New-HealthOutputDirectory -Path $OutputDirectory +$solution = 'CheatEngine.SDK.slnx' +$package = 'src/CheatEngine.SDK/CheatEngine.SDK.csproj' + +$selected = [string] ((Get-Content -Raw -LiteralPath (Join-Path -Path $root -ChildPath 'global.json') | ConvertFrom-Json -AsHashtable)['sdk']['version']) +$pinned = [string] (((Get-GitOutput -ArgumentList @('show', 'HEAD:global.json')) -join "`n" | ConvertFrom-Json -AsHashtable)['sdk']['version']) +$active = @(Get-NativeCommandOutput -FilePath 'dotnet' -ArgumentList @('--version') -Description 'Reading the active .NET SDK version')[0].Trim() +if ($active -cne $selected) { + throw "The active .NET SDK is $active, but global.json selects ${selected}: run the composite setup action (or install $selected) first." +} + +$sameSdk = $selected -ceq $pinned +$status = [ordered]@{ Build = 'Not run'; Pack = 'Not run'; Tests = if ($SkipTests) { 'Skipped (-SkipTests)' } else { 'Not run' } } +$failures = [System.Collections.Generic.List[string]]::new() +$patchPath = Join-Path -Path $output -ChildPath 'lock-files.patch' +$changedLocks = @() + +Push-Location -LiteralPath $root +try { + & (Join-Path -Path $root -ChildPath 'eng/Update-LockFiles.ps1') + if ($LASTEXITCODE -ne 0) { + throw "eng/Update-LockFiles.ps1 failed with exit code $LASTEXITCODE." + } + + [void] (Get-GitOutput -ArgumentList @('diff', "--output=$patchPath", '--', '*packages.lock.json')) + $changedLocks = @(Get-GitOutput -ArgumentList @('diff', '--name-only', '--', '*packages.lock.json')) + + @(Get-GitOutput -ArgumentList @('ls-files', '--others', '--exclude-standard', '--', '*packages.lock.json')) + if ($sameSdk -and $changedLocks.Count -gt 0) { + $failures.Add("With the pinned SDK $pinned, regenerating changed $($changedLocks.Count) committed lock file(s): run ./eng/Update-LockFiles.ps1 and commit the result.") + } + + Invoke-NativeCommand -FilePath 'dotnet' -Description "Release build with SDK $selected" ` + -ArgumentList @('build', $solution, '-c', 'Release', '--no-restore') + $status.Build = 'Succeeded' + + $packageDirectory = Join-Path -Path $output -ChildPath 'nuget' + Invoke-NativeCommand -FilePath 'dotnet' -Description "Pack with SDK $selected" ` + -ArgumentList @('pack', $package, '-c', 'Release', '--no-build', '-o', $packageDirectory) + $packed = @(Get-ChildItem -LiteralPath $packageDirectory -Filter 'CheatEngine.SDK.*.nupkg' -File) + if ($packed.Count -ne 1) { + throw "The pack produced $($packed.Count) CheatEngine.SDK packages in $packageDirectory; expected exactly one." + } + + $status.Pack = "Succeeded ($($packed[0].Name))" + + if (-not $SkipTests) { + $testArguments = @( + 'test', '--solution', $solution, '-c', 'Release', '--no-build', '--fail-skips', 'on', '--report-trx', + '--results-directory', (Join-Path -Path $output -ChildPath 'test-results')) + $testsProps = Get-Content -Raw -LiteralPath (Join-Path -Path $root -ChildPath 'eng/Tests.props') + if (Test-PackageReference -ProjectText $testsProps -PackageId 'Microsoft.Testing.Extensions.HangDump') { + $testArguments += @('--hangdump', '--hangdump-timeout', '15m') + } + + $previousPackage = $env:CESDK_PACKAGED_UMBRELLA_NUPKG + $env:CESDK_PACKAGED_UMBRELLA_NUPKG = $packed[0].FullName + try { + $testExitCode = Invoke-NativeCommand -FilePath 'dotnet' -ArgumentList $testArguments -AllowFailure ` + -Description "Release tests with SDK $selected" + } + finally { + $env:CESDK_PACKAGED_UMBRELLA_NUPKG = $previousPackage + } + + $totals = [ordered]@{ Total = 0; Passed = 0; Failed = 0; NotExecuted = 0 } + foreach ($report in @(Get-ChildItem -LiteralPath (Join-Path -Path $output -ChildPath 'test-results') -Filter '*.trx' -File -Recurse -ErrorAction SilentlyContinue)) { + $counters = Get-TrxSummary -Xml (Get-Content -Raw -LiteralPath $report.FullName) + foreach ($name in @($totals.Keys)) { + $totals[$name] += $counters.$name + } + } + + $status.Tests = "$(if ($testExitCode -eq 0) { 'Passed' } else { "Failed (exit code $testExitCode)" }): $($totals.Total) total, $($totals.Passed) passed, $($totals.Failed) failed, $($totals.NotExecuted) not executed" + if ($testExitCode -ne 0) { + $failures.Add("The Release tests failed with SDK $selected (exit code $testExitCode).") + } + } +} +catch { + $failures.Add($_.Exception.Message) +} +finally { + Pop-Location +} + +$comparison = if ($sameSdk) { + "The newest SDK equals the pinned SDK ($pinned): the canary rebuilt with the pinned SDK." +} +else { + "The canary ran with SDK $selected; global.json pins $pinned." +} + +$lockCell = [string] $changedLocks.Count +if ($changedLocks.Count -gt 0) { + $lockCell += ': ' + (($changedLocks | ForEach-Object { '`' + $_ + '`' }) -join ', ') +} + +$summary = @( + '## Newest .NET SDK canary', + '', + $comparison, + '', + '| Step | Result |', + '| --- | --- |', + "| Pinned SDK (HEAD global.json) | ``$pinned`` |", + "| Canary SDK | ``$selected`` |", + "| Lock files changed | $lockCell |", + "| Build (Release) | $($status.Build) |", + "| Pack | $(ConvertTo-HealthTableCell -Text $status.Pack) |", + "| Tests (Release) | $($status.Tests) |", + '' +) +if ($changedLocks.Count -gt 0 -and -not $sameSdk) { + $summary += 'The `health-sdk-canary` artifact holds `lock-files.patch` for the Dependabot `dotnet-sdk` pull request (eng/ci/health/README.md).' +} + +foreach ($failure in $failures) { + $summary += "- **Failed:** $(ConvertTo-HealthTableCell -Text $failure)" +} + +Write-HealthSummary -Line $summary +[System.IO.File]::WriteAllLines((Join-Path -Path $output -ChildPath 'summary.md'), [string[]] $summary, [System.Text.UTF8Encoding]::new($false)) + +if ($failures.Count -gt 0) { + throw "The SDK canary failed: $($failures -join ' ')" +} diff --git a/eng/ci/health/Invoke-TestRepeat.ps1 b/eng/ci/health/Invoke-TestRepeat.ps1 new file mode 100644 index 00000000..adc04e1e --- /dev/null +++ b/eng/ci/health/Invoke-TestRepeat.ps1 @@ -0,0 +1,124 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs the threading-sensitive test modules several times in a row and fails when any run fails. + +.DESCRIPTION + Required CI never retries a test (flaky-test policy, eng/ci/health/README.md): a test that fails intermittently is + fixed or deleted in the pull request that finds it, because --fail-skips on forbids hiding it with Skip. This script + is the weekly detector for tests that pass once and fail on a later run. It targets the modules whose tests drive the + real Lua state and the native bridge ([Trait("Category", "NativeLua")]): Lua, Lua.Interop and Hosting. + + 1. Restore each project in locked mode and build it once in Debug. + 2. For each iteration and project: `dotnet test --project

-c Debug --no-build --fail-skips on --report-trx` into + //, with --hangdump when eng/Tests.props references the HangDump extension + (without it, Microsoft.Testing.Platform rejects the option with exit code 5). + 3. Write a pass/fail grid (projects x iterations, with the failed-test count from each TRX report) to summary.md and + the job summary, and fail when any run failed. + +.PARAMETER Project + Test projects to repeat (repository-relative .csproj paths). Defaults to the three NativeLua modules. + +.PARAMETER Iterations + Number of runs per project. + +.PARAMETER OutputDirectory + Where the TRX reports, dumps and summary.md are written. + +.EXAMPLE + ./eng/ci/health/Invoke-TestRepeat.ps1 -Iterations 2 -OutputDirectory "$env:TEMP/repeat" +#> +[CmdletBinding()] +param( + [ValidateNotNullOrEmpty()] + [string[]] $Project = @( + 'tests/CheatEngine.SDK.Lua.Tests/CheatEngine.SDK.Lua.Tests.csproj', + 'tests/CheatEngine.SDK.Lua.Interop.Tests/CheatEngine.SDK.Lua.Interop.Tests.csproj', + 'tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj' + ), + + [ValidateRange(1, 50)] + [int] $Iterations = 5, + + [string] $OutputDirectory = 'artifacts/health/test-repeat' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCheck.psm1') -Force +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCommand.psm1') -Force + +$root = Get-HealthRepositoryRoot +$output = New-HealthOutputDirectory -Path $OutputDirectory +$testsProps = Get-Content -Raw -LiteralPath (Join-Path -Path $root -ChildPath 'eng/Tests.props') +$dumpArguments = @( + if (Test-PackageReference -ProjectText $testsProps -PackageId 'Microsoft.Testing.Extensions.HangDump') { + '--hangdump', '--hangdump-timeout', '10m' + }) + +# results[project][iteration] = cell text; a failed run keeps its exit code and failed-test count. +$results = [ordered]@{} +$failedRuns = 0 + +Push-Location -LiteralPath $root +try { + foreach ($testProject in $Project) { + if (-not (Test-Path -LiteralPath (Join-Path -Path $root -ChildPath $testProject) -PathType Leaf)) { + throw "The test project '$testProject' does not exist." + } + + Invoke-NativeCommand -FilePath 'dotnet' -ArgumentList @('restore', $testProject, '--locked-mode') -Description "Locked restore of $testProject" + Invoke-NativeCommand -FilePath 'dotnet' -ArgumentList @('build', $testProject, '-c', 'Debug', '--no-restore') -Description "Debug build of $testProject" + $results[$testProject] = [System.Collections.Generic.List[string]]::new() + } + + for ($iteration = 1; $iteration -le $Iterations; $iteration++) { + foreach ($testProject in $Project) { + $name = [System.IO.Path]::GetFileNameWithoutExtension($testProject) + $resultsDirectory = Join-Path -Path $output -ChildPath "$iteration/$name" + $arguments = @( + 'test', '--project', $testProject, '-c', 'Debug', '--no-build', '--fail-skips', 'on', '--report-trx', + '--results-directory', $resultsDirectory) + $dumpArguments + $exitCode = Invoke-NativeCommand -FilePath 'dotnet' -ArgumentList $arguments -AllowFailure ` + -Description "Iteration $iteration of $name" + + $failedTests = 0 + foreach ($report in @(Get-ChildItem -LiteralPath $resultsDirectory -Filter '*.trx' -File -Recurse -ErrorAction SilentlyContinue)) { + $failedTests += (Get-TrxSummary -Xml (Get-Content -Raw -LiteralPath $report.FullName)).Failed + } + + if ($exitCode -eq 0) { + $results[$testProject].Add('Passed') + } + else { + $failedRuns++ + $results[$testProject].Add("**Failed** ($failedTests failed, exit code $exitCode)") + } + } + } +} +finally { + Pop-Location +} + +$header = '| Project | ' + ((1..$Iterations | ForEach-Object { "Run $_" }) -join ' | ') + ' |' +$separator = '| --- |' + (' --- |' * $Iterations) +$summary = @( + '## Repeated threading-sensitive tests', + '', + "$Iterations runs of each module in Debug, with --fail-skips on$(if ($dumpArguments.Count) { ' and hang dumps' }).", + '', + $header, + $separator +) +foreach ($testProject in $results.Keys) { + $summary += "| $([System.IO.Path]::GetFileNameWithoutExtension($testProject)) | $($results[$testProject] -join ' | ') |" +} + +Write-HealthSummary -Line $summary +[System.IO.File]::WriteAllLines((Join-Path -Path $output -ChildPath 'summary.md'), [string[]] $summary, [System.Text.UTF8Encoding]::new($false)) + +if ($failedRuns -gt 0) { + throw "$failedRuns of $($Iterations * $Project.Count) test runs failed: a test is flaky or broken. Fix or delete it (eng/ci/health/README.md#flaky-tests)." +} diff --git a/eng/ci/health/Invoke-VulnerabilityAudit.ps1 b/eng/ci/health/Invoke-VulnerabilityAudit.ps1 new file mode 100644 index 00000000..6e516438 --- /dev/null +++ b/eng/ci/health/Invoke-VulnerabilityAudit.ps1 @@ -0,0 +1,130 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Strict NuGet audit of every project: any known vulnerability, of any severity, direct or transitive, fails. + +.DESCRIPTION + Ordinary builds fail only on high and critical advisories (NU1903, NU1904), so that an advisory published overnight + does not turn every required check red without a commit (eng/api/README.md#nuget-audit). This script is the + dedicated audit run of that policy, used by the weekly scheduled health workflow: + + 1. Restore the solution, then every tracked project outside it, with `--locked-mode --force -p:AuditPipeline=true`. + AuditPipeline makes every audit code an error (NU1900 to NU1905, Directory.Build.props); --force makes restore + re-evaluate, so the audit runs even when the assets are up to date (RestoreForce, which unlike + --force-evaluate is compatible with locked mode). Under CI, Directory.Solution.targets also asserts that every + project of the solution was audited. + https://learn.microsoft.com/nuget/concepts/auditing-packages#running-nuget-audit-in-ci + 2. List the vulnerable packages (--vulnerable --include-transitive) and the deprecated packages (--deprecated) of + each target as JSON, with --no-restore (the .NET 10 command restores implicitly otherwise). + https://learn.microsoft.com/dotnet/core/tools/dotnet-package-list + 3. Print both tables, write them to the job summary and to , and fail when a restore failed or any + package is vulnerable. Deprecated packages are reported as a warning. + + The global properties never replace WarningsAsErrors: CESDK9009 fails a restore whose audit policy is weakened. + +.PARAMETER OutputDirectory + Where vulnerable.json, deprecated.json and summary.md are written (repository-relative or absolute). + +.EXAMPLE + ./eng/ci/health/Invoke-VulnerabilityAudit.ps1 -OutputDirectory "$env:TEMP/audit" +#> +[CmdletBinding()] +param( + [string] $OutputDirectory = 'artifacts/health/audit' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCheck.psm1') -Force +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCommand.psm1') -Force + +$root = Get-HealthRepositoryRoot +$output = New-HealthOutputDirectory -Path $OutputDirectory +$targets = @(Get-RepositoryRestoreTarget) +$restoreFailures = [System.Collections.Generic.List[string]]::new() +$vulnerable = [System.Collections.Generic.List[object]]::new() +$deprecated = [System.Collections.Generic.List[object]]::new() +$reports = [ordered]@{ Vulnerable = [System.Collections.Generic.List[object]]::new(); Deprecated = [System.Collections.Generic.List[object]]::new() } + +Push-Location -LiteralPath $root +try { + foreach ($target in $targets) { + # Keep going after a failed restore: the report must list every project, not only the first failure. + $exitCode = Invoke-NativeCommand -FilePath 'dotnet' -AllowFailure -Description "Strict audit restore of $target" ` + -ArgumentList @('restore', $target, '--locked-mode', '--force', '-p:AuditPipeline=true') + if ($exitCode -ne 0) { + $restoreFailures.Add("$target (exit code $exitCode)") + } + } + + foreach ($target in $targets) { + foreach ($kind in @('Vulnerable', 'Deprecated')) { + $kindArguments = @(if ($kind -ceq 'Vulnerable') { '--vulnerable', '--include-transitive' } else { '--deprecated' }) + $json = (Get-NativeCommandOutput -FilePath 'dotnet' -Description "Listing the $($kind.ToLowerInvariant()) packages of $target" ` + -ArgumentList (@('package', 'list', '--project', $target) + $kindArguments + @('--format', 'json', '--no-restore'))) -join "`n" + $reports[$kind].Add(($json | ConvertFrom-Json)) + $rows = @(ConvertFrom-PackageListReport -Json $json -Kind $kind -RepositoryRoot $root) + if ($kind -ceq 'Vulnerable') { $vulnerable.AddRange($rows) } else { $deprecated.AddRange($rows) } + } + } +} +finally { + Pop-Location +} + +foreach ($kind in $reports.Keys) { + $path = Join-Path -Path $output -ChildPath "$($kind.ToLowerInvariant()).json" + [System.IO.File]::WriteAllText($path, (ConvertTo-Json -InputObject @($reports[$kind]) -Depth 20), [System.Text.UTF8Encoding]::new($false)) +} + +function ConvertTo-PackageTable { + param( + [AllowEmptyCollection()] [Parameter(Mandatory)] [object[]] $Row, + [Parameter(Mandatory)] [string] $Empty + ) + + if ($Row.Count -eq 0) { + return @($Empty) + } + + $table = @('| Project | Framework | Package | Resolved | Transitive | Detail |', '| --- | --- | --- | --- | --- | --- |') + foreach ($item in $Row) { + $cells = @($item.Project, $item.Framework, $item.Package, $item.Resolved, $(if ($item.Transitive) { 'yes' } else { 'no' }), $item.Detail) | + ForEach-Object { ConvertTo-HealthTableCell -Text $_ } + $table += "| $($cells -join ' | ') |" + } + + return $table +} + +$summary = @( + '## Strict NuGet audit', + '', + "$($targets.Count) restore targets (the solution and every project outside it), audited at every severity with ``-p:AuditPipeline=true``.", + '' +) +if ($restoreFailures.Count -gt 0) { + $summary += "**Restore failed** for: $($restoreFailures -join ', '). The NU19xx errors in the log name the advisories." + $summary += '' +} + +$summary += '### Vulnerable packages' +$summary += '' +$summary += ConvertTo-PackageTable -Row $vulnerable.ToArray() -Empty 'No known vulnerability in any direct or transitive package.' +$summary += '' +$summary += '### Deprecated packages' +$summary += '' +$summary += ConvertTo-PackageTable -Row $deprecated.ToArray() -Empty 'No deprecated package.' +Write-HealthSummary -Line $summary +[System.IO.File]::WriteAllLines((Join-Path -Path $output -ChildPath 'summary.md'), [string[]] $summary, [System.Text.UTF8Encoding]::new($false)) + +if ($deprecated.Count -gt 0) { + Write-Host "::warning title=Deprecated packages::$($deprecated.Count) deprecated package reference(s); see the job summary." +} + +if ($restoreFailures.Count -gt 0 -or $vulnerable.Count -gt 0) { + throw "The strict NuGet audit failed: $($restoreFailures.Count) restore failure(s), $($vulnerable.Count) vulnerable package reference(s)." +} + +Write-Host 'The strict NuGet audit passed.' diff --git a/eng/ci/health/Publish-HealthIssue.ps1 b/eng/ci/health/Publish-HealthIssue.ps1 new file mode 100644 index 00000000..c1c02940 --- /dev/null +++ b/eng/ci/health/Publish-HealthIssue.ps1 @@ -0,0 +1,98 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Opens, or comments on, the single "Scheduled health check needs attention" issue when a scheduled health run found + a problem. + +.DESCRIPTION + Runs in the notify job of .github/workflows/scheduled-health.yml, for scheduled runs only, with a token that may + write issues and nothing else. + + 1. Decide with Get-HealthIssueReport (HealthCheck.psm1): the run needs attention when a job did not succeed, when the + release bridge rebuild drifted, or when broken links were found. The body is built from closed vocabularies only + (job ids, job results, the run URL): no text from a log, a pull request or an issue reaches it. + 2. Nothing to report: print it and stop. + 3. Issues disabled on the repository (has_issues false): print a ::warning:: and stop. + 4. Look for an OPEN issue with exactly that title (Select-HealthIssue) and comment on it; otherwise create it with + the label -Label. One issue accumulates the failures until a maintainer closes it. + +.PARAMETER NeedsJson + toJSON(needs) of the notify job. Defaults to $env:NEEDS. + +.PARAMETER RunUrl + URL of the workflow run. Defaults to $env:RUN_URL. + +.PARAMETER Drift + 'true' when the bridge rebuild drifted. Defaults to $env:DRIFT. + +.PARAMETER BrokenLinks + 'true' when broken external links were found. Defaults to $env:BROKEN_LINKS. + +.PARAMETER Repository + owner/name. Defaults to $env:GH_REPO (gh also reads GH_REPO and GH_TOKEN). + +.PARAMETER Label + Label of a newly created issue. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string] $NeedsJson = $env:NEEDS, + [string] $RunUrl = $env:RUN_URL, + [string] $Drift = $env:DRIFT, + [string] $BrokenLinks = $env:BROKEN_LINKS, + [string] $Repository = $env:GH_REPO, + [string] $Label = 'ci' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCheck.psm1') -Force +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCommand.psm1') -Force + +$report = Get-HealthIssueReport -NeedsJson $NeedsJson -Drift ([string] $Drift) -BrokenLinks ([string] $BrokenLinks) -RunUrl ([string] $RunUrl) +if (-not $report.NeedsAttention) { + Write-HealthSummary -Line @('## Report health', '', 'Every scheduled health job succeeded: no issue to open or update.') + return +} + +if (-not [regex]::IsMatch([string] $Repository, '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')) { + throw "GH_REPO must be owner/name (got '$Repository')." +} + +$hasIssues = @(Get-NativeCommandOutput -FilePath 'gh' -ArgumentList @('api', "repos/$Repository", '--jq', '.has_issues') -Description 'Reading has_issues')[0] +if ([string] $hasIssues -ceq 'false') { + Write-Host "::warning title=Scheduled health::Issues are disabled on $Repository; the health problems are only in this run's summary." + Write-HealthSummary -Line @('## Report health', '', $report.Body) + return +} + +$openIssues = (Get-NativeCommandOutput -FilePath 'gh' -Description 'Listing the open issues' -ArgumentList @( + 'issue', 'list', '--repo', $Repository, '--state', 'open', '--json', 'number,title', '--limit', '500')) -join "`n" +$existing = Select-HealthIssue -IssueListJson $openIssues + +$bodyFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "health-issue-$([guid]::NewGuid().ToString('N')).md") +[System.IO.File]::WriteAllText($bodyFile, $report.Body, [System.Text.UTF8Encoding]::new($false)) +try { + if ($null -ne $existing) { + if ($PSCmdlet.ShouldProcess("$Repository#$existing", 'Comment on the scheduled health issue')) { + Invoke-NativeCommand -FilePath 'gh' -Description "Commenting on issue #$existing" -ArgumentList @( + 'issue', 'comment', [string] $existing, '--repo', $Repository, '--body-file', $bodyFile) + } + + $outcome = "Commented on the open issue #$existing." + } + else { + if ($PSCmdlet.ShouldProcess($Repository, "Create the issue '$($report.Title)'")) { + Invoke-NativeCommand -FilePath 'gh' -Description 'Creating the scheduled health issue' -ArgumentList @( + 'issue', 'create', '--repo', $Repository, '--title', $report.Title, '--label', $Label, '--body-file', $bodyFile) + } + + $outcome = "Opened the issue '$($report.Title)'." + } +} +finally { + Remove-Item -LiteralPath $bodyFile -Force -ErrorAction SilentlyContinue +} + +Write-HealthSummary -Line @('## Report health', '', $outcome, '', $report.Body) diff --git a/eng/ci/health/README.md b/eng/ci/health/README.md new file mode 100644 index 00000000..650a15ab --- /dev/null +++ b/eng/ci/health/README.md @@ -0,0 +1,137 @@ +# Scheduled health checks + +The scripts of [`.github/workflows/scheduled-health.yml`](../../../.github/workflows/scheduled-health.yml), a weekly +workflow (Monday 02:37 UTC, and on manual dispatch) that watches what changes without a commit of this repository: new +security advisories, a newer .NET SDK, the toolchain that rebuilds the released native bridge, intermittent test +failures and external links. It is advisory: it is not part of `CI / Gate`, and a failure opens or updates one issue, +**Scheduled health check needs attention** (label `ci`), instead of blocking a pull request. + +Audit anchors: register rows PR-CQ-55 (the workflow), PR-CQ-30 (flaky tests), A21-25 (release bridge rebuild); +audit chapter 21, "exit criteria": the package can be rebuilt from its release commit, and its version and content are +locked. + +## Jobs + +| Job | Script | Fails when | Artifact | +|---|---|---|---| +| `audit` (Strict NuGet audit) | `Invoke-VulnerabilityAudit.ps1` | any restore reports an advisory of any severity (NU1900 to NU1905), or any package is listed as vulnerable | none (job summary) | +| `canary` (Newest .NET SDK canary) | `Set-CanarySdkVersion.ps1`, then `Invoke-SdkCanary.ps1` | the build, the pack or a Release test fails with the newest SDK, or the pinned SDK regenerates different lock files | `health-sdk-canary`, 14 days: `lock-files.patch`, `summary.md`, TRX reports | +| `test-repeat` (Repeat threading-sensitive tests) | `Invoke-TestRepeat.ps1` | any of five runs of the Lua, Lua.Interop or Hosting test module fails | `health-test-repeat`, 14 days, on failure: TRX reports and dumps | +| `bridge-drift` (Release bridge rebuild) | `Invoke-BridgeDriftCheck.ps1` | the rebuild is `Failed`; `ToolchainDrift` is a warning that still opens the issue | `health-bridge-drift`, 30 days: `bridge-drift.json`, `summary.md`, the rebuilt DLL | +| `links` (External documentation links) | `Test-ExternalLink.ps1` | never; broken links set its `broken` output | none (job summary) | +| `notify` (Report health) | `Publish-HealthIssue.ps1` | cannot reach the issues API | none | + +`notify` runs for scheduled runs only, with the only write permission of the workflow (`issues: write`). Every +decision the scripts take (tag selection, drift classification, SDK selection, report parsing, link verdicts, the issue +body) lives in the pure module `HealthCheck.psm1`, which `HealthCheckScriptTests` in +[`tests/CheatEngine.SDK.Repository.Tests`](../../../tests/CheatEngine.SDK.Repository.Tests/README.md) exercises offline; +`HealthCommand.psm1` holds the side-effect helpers (native commands with exit-code checks, git, job outputs). + +## Strict NuGet audit + +Ordinary restores fail only on high and critical advisories; the other audit codes are warnings, so that an advisory +published overnight does not turn every required check red without a commit (see +[`eng/api/README.md`](../../api/README.md#nuget-audit)). This job is the dedicated audit run of that policy: it restores +the solution and every project outside it with `--locked-mode --force -p:AuditPipeline=true` (every audit code becomes +an error; `--force` makes the audit run even when the assets are up to date), then lists the vulnerable (direct and +transitive) and deprecated packages as JSON with `dotnet package list ... --no-restore`. + +When it fails: upgrade the package (or the package that brings it transitively, `dotnet nuget why`), regenerate the lock +files with `./eng/Update-LockFiles.ps1`, and open a pull request. Suppressing an advisory is a last resort with an +expiry date ([`eng/api/README.md`](../../api/README.md#nuget-audit)); an expired suppression fails this job. + +## Newest .NET SDK canary + +`global.json` pins the SDK exactly (`rollForward: disable`) because the lock files record the packages the SDK adds +implicitly (ILLink, ILCompiler). The canary tells, before the Dependabot `dotnet-sdk` pull request arrives, what the +next SDK of the same channel changes: + +1. `Set-CanarySdkVersion.ps1` reads `latest-sdk` of the channel's + [release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/10.0/releases.json) and rewrites only + `sdk.version` in the runner's checkout (the test runner section stays: without it `dotnet test` would use VSTest). + It runs before the composite setup action, which installs exactly the SDK `global.json` names. +2. `Invoke-SdkCanary.ps1` regenerates the lock files with `./eng/Update-LockFiles.ps1`, saves the difference as + `lock-files.patch`, builds the solution in Release, packs `src/CheatEngine.SDK` and runs every Release test module with + `CESDK_PACKAGED_UMBRELLA_NUPKG` set to that package. + +When the newest SDK is the pinned one, the summary says so, the canary rebuilds with the pinned SDK, and the patch must +be empty; a non-empty patch then means the committed lock files are stale. + +To apply the patch to the Dependabot `dotnet-sdk` pull request (on Windows, with the new SDK installed): + +```powershell +gh pr checkout +git apply lock-files.patch # from the health-sdk-canary artifact of a run with that SDK +./eng/Update-LockFiles.ps1 -Verify # must pass with the new SDK +``` + +Then update the SDK version mentioned in `global.json`'s `errorMessage` and in the documentation, commit and push. +Dependabot stops rebasing a pull request once someone else pushes to it. + +## Flaky tests + +Required runs never retry a test. A test that fails intermittently is fixed or deleted in the pull request that finds +it: `--fail-skips on` makes a skipped test a failure, so it cannot be hidden with `Skip`. `test-repeat` is the weekly +detector for tests that pass once and fail on a later run: it builds the three modules whose tests drive the real Lua +state and the native bridge (`[Trait("Category", "NativeLua")]`) once in Debug, runs each five times, and writes a +pass/fail grid. Hang dumps are collected when `eng/Tests.props` references the HangDump extension. + +## Release bridge rebuild + +`Invoke-BridgeDriftCheck.ps1` rebuilds the native protection bridge of the newest release tag that ships one (the +v0.x tags of the former CESDK package do not), from the tag's committed `cheatengine_sdk_lua_bridge.c` and `xmake.lua` +bytes, twice, in two temporary directories of different depth outside the checkout, with the same toolchain pins as the +`native` job of `ci.yml` (`BRIDGE_VS_TOOLSET`, `BRIDGE_VS_SDKVER`, compared by `GovernanceWorkflowTests`). It compares +the result with the DLL committed at the tag and with `build/native/cheatengine-sdk-lua-bridge.dll` inside the released +package on nuget.org: + +| Classification | Meaning | What to do | +|---|---|---| +| `Reproduced` | today's toolchain rebuilds the released bytes | nothing | +| `ToolchainDrift` | the sources and fingerprint match the release, the bytes differ | read the toolchain facts in the report (MSVC toolset, compiler, Windows SDK, runner image). The released package stays valid; the next release will carry other bytes, and its notes should say why | +| `Failed` | the two rebuilds differ (path dependence), the rebuild does not export the tag's source fingerprint, the tag's committed DLL is not the released one, or the package could not be read | investigate before the next release: the release is not reproducible as recorded | + +Run it locally (Windows, xmake 3.0.9 and MSVC): + +```powershell +./eng/ci/health/Invoke-BridgeDriftCheck.ps1 -Tag v1.0.0 -OutputDirectory "$env:TEMP/drift" +``` + +## External links + +`Test-ExternalLink.ps1` requests every external `http(s)` link of the tracked Markdown files (fenced code, code spans, +local hosts, example domains and templated URLs are skipped; links into this repository's `main` branch are checked offline by the +documentation tests). 404 and 410 are broken; rate limiting, server errors and timeouts are inconclusive warnings. A +pull request never depends on an external site. + +## Running the scripts locally + +Every script runs on a developer machine from PowerShell 7, from the repository root, after the .NET SDK of +`global.json` is installed. Two of them change the working tree and belong in a throwaway clone: + +```powershell +git clone --no-hardlinks . "$env:TEMP/canary" +Set-Location "$env:TEMP/canary" +./eng/ci/health/Set-CanarySdkVersion.ps1 -SdkVersion 10.0.401 # rewrites global.json in the clone +./eng/ci/health/Invoke-SdkCanary.ps1 -SkipTests -OutputDirectory "$env:TEMP/canary-out" +``` + +The others only write under `-OutputDirectory` (default `artifacts/health/`, which git ignores): + +```powershell +./eng/ci/health/Invoke-VulnerabilityAudit.ps1 +./eng/ci/health/Invoke-TestRepeat.ps1 -Iterations 2 +./eng/ci/health/Test-ExternalLink.ps1 +``` + +`Publish-HealthIssue.ps1` writes to GitHub and runs in the workflow only. + +## Scheduling and validation + +- GitHub disables a scheduled workflow of a public repository after 60 days without repository activity; re-enable it + from the Actions tab + ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)). +- A workflow can be dispatched only once it is on the default branch, so the first run of this workflow happens after + it is merged: `gh workflow run scheduled-health.yml`, then check each job summary. +- The `canary` job may pass `cache: 'true'` to the composite action (the one workflow allowed to); it does not, so every + health run restores from nuget.org like the release path. diff --git a/eng/ci/health/Set-CanarySdkVersion.ps1 b/eng/ci/health/Set-CanarySdkVersion.ps1 new file mode 100644 index 00000000..62eb555c --- /dev/null +++ b/eng/ci/health/Set-CanarySdkVersion.ps1 @@ -0,0 +1,83 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + First stage of the newest-SDK canary: writes the newest .NET SDK of the pinned channel into global.json. + +.DESCRIPTION + global.json pins the SDK exactly (rollForward: disable), and the lock files record the implicit packages of that SDK + (ILLink, ILCompiler). The canary tells ahead of a Dependabot dotnet-sdk pull request what the next SDK changes. + This stage runs BEFORE the composite setup action, which installs the SDK that global.json names, so it uses no + .NET CLI at all: + + 1. Read sdk.version of global.json and derive its channel (10.0.401 -> 10.0). + 2. Take -SdkVersion, or latest-sdk of the channel's release metadata + (https://builds.dotnet.microsoft.com/dotnet/release-metadata//releases.json). + 3. Rewrite only sdk.version, keeping rollForward, allowPrerelease, errorMessage and the test runner section: + removing global.json would also drop "test": { "runner": "Microsoft.Testing.Platform" } and put the test command + in VSTest mode. https://learn.microsoft.com/dotnet/core/tools/global-json + 4. Write pinned-version, sdk-version and changed to GITHUB_OUTPUT, and a line to the job summary. + + The rewrite happens in the CI runner's checkout (or a throwaway local clone): never commit it. A new SDK reaches the + repository through the dotnet-sdk Dependabot pull request, with the lock files regenerated by eng/Update-LockFiles.ps1. + +.PARAMETER SdkVersion + Use this full SDK version (for example 10.0.402) instead of the channel's latest-sdk. + +.PARAMETER GlobalJsonPath + The global.json to rewrite. Defaults to the repository's. + +.EXAMPLE + ./eng/ci/health/Set-CanarySdkVersion.ps1 +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string] $SdkVersion, + [string] $GlobalJsonPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCheck.psm1') -Force +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCommand.psm1') -Force + +if (-not $GlobalJsonPath) { + $GlobalJsonPath = Join-Path -Path (Get-HealthRepositoryRoot) -ChildPath 'global.json' +} + +$json = Get-Content -Raw -LiteralPath $GlobalJsonPath +$pinned = [string] ($json | ConvertFrom-Json -AsHashtable)['sdk']['version'] +$channel = Get-SdkChannel -Version $pinned + +if ($SdkVersion) { + $newest = $SdkVersion + if ((Get-SdkChannel -Version $newest) -cne $channel) { + throw "-SdkVersion $newest is not on the $channel channel of global.json ($pinned); a new major is a migration, not a canary." + } +} +else { + $metadataUrl = "https://builds.dotnet.microsoft.com/dotnet/release-metadata/$channel/releases.json" + Write-Host "Reading the newest SDK of channel $channel from $metadataUrl." + $metadata = Invoke-WebRequest -Uri $metadataUrl -MaximumRetryCount 3 -RetryIntervalSec 5 -UseBasicParsing + $newest = Get-NewestSdkVersion -ReleasesJson ([string] $metadata.Content) -Channel $channel +} + +$changed = $newest -cne $pinned +if ($changed -and $PSCmdlet.ShouldProcess($GlobalJsonPath, "Set sdk.version to $newest")) { + $rewritten = Get-UpdatedGlobalJson -Json $json -Version $newest + [System.IO.File]::WriteAllText($GlobalJsonPath, $rewritten + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) +} + +Write-HealthOutput -Value ([ordered]@{ + 'pinned-version' = $pinned + 'sdk-version' = $newest + 'changed' = $(if ($changed) { 'true' } else { 'false' }) + }) +$line = if ($changed) { + "SDK canary: global.json now selects $newest (pinned: $pinned) for this run only." +} +else { + "SDK canary: the newest SDK of channel $channel is the pinned SDK $pinned; the canary rebuilds with it." +} + +Write-HealthSummary -Line @('## Newest .NET SDK canary', '', $line, '') diff --git a/eng/ci/health/Test-ExternalLink.ps1 b/eng/ci/health/Test-ExternalLink.ps1 new file mode 100644 index 00000000..625200bb --- /dev/null +++ b/eng/ci/health/Test-ExternalLink.ps1 @@ -0,0 +1,126 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Checks the external links of the tracked Markdown files and reports broken ones without failing. + +.DESCRIPTION + External URLs change without a commit, so they are checked here, weekly, and never in a pull request gate. Links back + into this repository's main branch are not requested: the repository tests resolve them offline + (DocumentationIntegrityTests). + + 1. Collect the http(s) targets of every tracked *.md file (Get-ExternalLinkTarget in HealthCheck.psm1: fenced code, + code spans, local hosts, example domains and templated URLs are skipped). + 2. Request each URL with HEAD, falling back to GET when the server refuses HEAD or answers 404/410 to it, following + redirects, with up to three attempts and a growing pause for inconclusive answers. + 3. Classify (Get-ExternalLinkVerdict): 404 and 410 are broken; rate limiting (429, 403), server errors and timeouts + are inconclusive warnings. + 4. Write links.json and the job summary, and broken=true|false to GITHUB_OUTPUT. The script exits 0 whatever it + finds: the scheduled health workflow turns broken=true into its issue. + +.PARAMETER OutputDirectory + Where links.json is written. + +.PARAMETER TimeoutSeconds + Timeout of each request. + +.EXAMPLE + ./eng/ci/health/Test-ExternalLink.ps1 -OutputDirectory "$env:TEMP/links" +#> +[CmdletBinding()] +param( + [string] $OutputDirectory = 'artifacts/health/links', + + [ValidateRange(1, 120)] + [int] $TimeoutSeconds = 20 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCheck.psm1') -Force +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'HealthCommand.psm1') -Force + +# Repository-specific constant (the CheatEngine.Client twin changes only this line). +$RepositorySlug = 'CheatEngineNet/CheatEngine.SDK' +$MaximumAttempts = 3 +$UserAgent = "$RepositorySlug scheduled link check" + +$root = Get-HealthRepositoryRoot +$output = New-HealthOutputDirectory -Path $OutputDirectory + +function Get-LinkStatus { + param( + [Parameter(Mandatory)] [string] $Url, + [Parameter(Mandatory)] [ValidateSet('Head', 'Get')] [string] $Method, + [Parameter(Mandatory)] [int] $Timeout + ) + + try { + $response = Invoke-WebRequest -Uri $Url -Method $Method -SkipHttpErrorCheck -MaximumRedirection 10 -TimeoutSec $Timeout ` + -UserAgent $UserAgent -UseBasicParsing + return [int] $response.StatusCode + } + catch { + # DNS failures, TLS errors, timeouts and redirect loops have no status: inconclusive, never broken. + Write-Verbose "$Method $Url failed: $($_.Exception.Message)" + return 0 + } +} + +$locations = [ordered]@{} +foreach ($file in @(Get-GitOutput -ArgumentList @('ls-files', '--', '*.md'))) { + $text = Get-Content -Raw -LiteralPath (Join-Path -Path $root -ChildPath $file) + foreach ($url in @(Get-ExternalLinkTarget -Markdown ([string] $text) -RepositorySlug $RepositorySlug)) { + if (-not $locations.Contains($url)) { + $locations[$url] = $file + } + } +} + +$results = [System.Collections.Generic.List[object]]::new() +foreach ($url in $locations.Keys) { + $status = 0 + $verdict = 'Inconclusive' + for ($attempt = 1; $attempt -le $MaximumAttempts; $attempt++) { + $status = Get-LinkStatus -Url $url -Method Head -Timeout $TimeoutSeconds + # Some servers refuse HEAD (405, 501, 403) or answer it differently: GET decides before a link counts as broken. + if ($status -in @(0, 403, 404, 405, 410, 501)) { + $status = Get-LinkStatus -Url $url -Method Get -Timeout $TimeoutSeconds + } + + $verdict = Get-ExternalLinkVerdict -StatusCode $status + if ($verdict -cne 'Inconclusive' -or $attempt -eq $MaximumAttempts) { + break + } + + Start-Sleep -Seconds (5 * $attempt) + } + + $results.Add([pscustomobject]@{ Url = $url; File = $locations[$url]; Status = $status; Verdict = $verdict }) +} + +$broken = @($results | Where-Object { $_.Verdict -ceq 'Broken' }) +$inconclusive = @($results | Where-Object { $_.Verdict -ceq 'Inconclusive' }) +[System.IO.File]::WriteAllText((Join-Path -Path $output -ChildPath 'links.json'), (ConvertTo-Json -InputObject @($results) -Depth 3), + [System.Text.UTF8Encoding]::new($false)) + +$summary = @( + '## External documentation links', + '', + "$($results.Count) distinct external links in tracked Markdown files: $($results.Count - $broken.Count - $inconclusive.Count) ok, $($broken.Count) broken, $($inconclusive.Count) inconclusive.", + '' +) +if ($broken.Count + $inconclusive.Count -gt 0) { + $summary += '| Verdict | Status | Link | First found in |' + $summary += '| --- | --- | --- | --- |' + foreach ($item in @($broken) + @($inconclusive)) { + $summary += "| $($item.Verdict) | $(if ($item.Status) { $item.Status } else { 'no answer' }) | $(ConvertTo-HealthTableCell -Text $item.Url) | ``$($item.File)`` |" + } +} + +Write-HealthSummary -Line $summary +foreach ($item in $broken) { + Write-Host "::warning title=Broken link::$($item.File): $($item.Url) answered $($item.Status)." +} + +Write-HealthOutput -Value ([ordered]@{ broken = $(if ($broken.Count -gt 0) { 'true' } else { 'false' }) }) diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceScriptSyntaxTests.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceScriptSyntaxTests.cs new file mode 100644 index 00000000..36b4c168 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceScriptSyntaxTests.cs @@ -0,0 +1,52 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +///

+/// Every PowerShell script and module of the governance and health tooling parses. PSScriptAnalyzer reports syntax +/// errors with the separate ParseError severity, which a Severity = Error, Warning profile filters out, and +/// most of these scripts only run weekly or after merge, so a syntax error would otherwise surface in production. +/// +public sealed class GovernanceScriptSyntaxTests +{ + private static readonly string[] s_roots = ["eng/ci", "eng/github"]; + + [Fact] + public async Task Every_governance_script_parses_without_errors() + { + List scripts = []; + foreach (string root in s_roots) + { + string full = RepositoryFile.FullPath(root); + if (!Directory.Exists(full)) + { + continue; + } + + foreach (string pattern in (string[]) ["*.ps1", "*.psm1"]) + { + foreach (string file in Directory.EnumerateFiles(full, pattern, SearchOption.AllDirectories)) + { + scripts.Add(file); + } + } + } + + Assert.NotEmpty(scripts); + string list = string.Join(", ", scripts.ConvertAll(PwshScript.Quote)); + string script = $$""" + $problems = foreach ($path in @({{list}})) { + $tokens = $null + $errors = $null + [void] [System.Management.Automation.Language.Parser]::ParseFile($path, [ref] $tokens, [ref] $errors) + foreach ($parseError in $errors) { + "$($path):$($parseError.Extent.StartLineNumber): $($parseError.Message)" + } + } + $problems | ForEach-Object { Write-Output $_ } + """; + + PwshResult run = await PwshScript.RunTextAsync(script); + + Assert.True(run.ExitCode == 0, run.Transcript); + Assert.True(string.IsNullOrWhiteSpace(run.StandardOutput), $"PowerShell syntax errors:{Environment.NewLine}{run.StandardOutput}"); + } +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.ScheduledHealth.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.ScheduledHealth.cs new file mode 100644 index 00000000..fa24f995 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/GovernanceWorkflowTests.ScheduledHealth.cs @@ -0,0 +1,242 @@ +using System.Text.RegularExpressions; + +using YamlDotNet.RepresentationModel; + +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// .github/workflows/scheduled-health.yml (audit register PR-CQ-55, PR-CQ-30, A21-25): weekly, serialized, +/// issue writes from scheduled runs only, the canary SDK selected before .NET is installed, and the release bridge +/// rebuilt with the toolchain pins of the native job. +/// +public sealed partial class GovernanceWorkflowTests +{ + private const string PipelineWorkflow = ".github/workflows/ci.yml"; + private const string SetupAction = "./.github/actions/setup-dotnet"; + + [Fact] + public void Scheduled_health_runs_weekly_and_on_dispatch_one_run_at_a_time() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.ScheduledHealth); + + Assert.Equal(["schedule", "workflow_dispatch"], workflow.Triggers); + YamlMappingNode schedule = Assert.Single(YamlDocument.Mappings(YamlDocument.Child(workflow.Root, "on"), "schedule")); + Assert.Matches(WeeklyCron(), YamlDocument.Scalar(schedule, "cron") ?? ""); + // A queued run waits: cancelling a run that is about to report would lose its issue update. + YamlNode? concurrency = YamlDocument.Child(workflow.Root, "concurrency"); + Assert.Equal("scheduled-health", YamlDocument.Scalar(concurrency, "group")); + Assert.Equal("false", YamlDocument.Scalar(concurrency, "cancel-in-progress")); + Assert.Equal(["audit", "canary", "test-repeat", "bridge-drift", "links", "notify"], YamlDocument.KeysOf(YamlDocument.Child(workflow.Root, "jobs"))); + } + + [Fact] + public void Scheduled_health_opens_issues_only_from_scheduled_runs() + { + YamlDocument workflow = YamlDocument.Load(GovernanceWorkflows.ScheduledHealth); + YamlMappingNode notify = workflow.Job("notify"); + + Assert.Equal("${{ always() && github.event_name == 'schedule' }}", YamlDocument.Scalar(notify, "if")); + Assert.Equal(["audit", "canary", "test-repeat", "bridge-drift", "links"], YamlDocument.Scalars(notify, "needs")); + Assert.Equal(new Dictionary(StringComparer.Ordinal) { ["contents"] = "read", ["issues"] = "write" }, + YamlDocument.Permissions(notify)); + + // issues: write exists nowhere else among the governance workflows. + foreach (string path in GovernanceWorkflows.Existing()) + { + foreach (KeyValuePair job in YamlDocument.Load(path).Jobs) + { + bool writesIssues = YamlDocument.Permissions(job.Value)?.TryGetValue("issues", out string? access) == true + && string.Equals(access, "write", StringComparison.Ordinal); + Assert.True(!writesIssues || (string.Equals(path, GovernanceWorkflows.ScheduledHealth, StringComparison.Ordinal) + && string.Equals(job.Key, "notify", StringComparison.Ordinal)), + $"{path} job {job.Key} may not write issues."); + } + } + + // The only inputs of the issue: needs, two booleans, the run URL and the repository (closed vocabularies). + YamlMappingNode publish = Assert.Single(YamlDocument.Steps(notify), static step => YamlDocument.Child(step, "env") is not null); + Assert.Equal(["NEEDS", "DRIFT", "BROKEN_LINKS", "RUN_URL", "GH_REPO", "GH_TOKEN"], YamlDocument.KeysOf(YamlDocument.Child(publish, "env"))); + Assert.Equal("${{ toJSON(needs) }}", YamlDocument.Scalar(YamlDocument.Child(publish, "env"), "NEEDS")); + YamlMappingNode checkout = Assert.Single(YamlDocument.Steps(notify), static step => YamlDocument.UsesAction(step, "actions/checkout")); + Assert.Equal("eng/ci/health", YamlDocument.Scalar(YamlDocument.Child(checkout, "with"), "sparse-checkout")); + } + + [Fact] + public void Scheduled_health_canary_rewrites_global_json_before_the_composite_action() + { + IReadOnlyList steps = YamlDocument.Steps(YamlDocument.Load(GovernanceWorkflows.ScheduledHealth).Job("canary")); + + int checkout = IndexWhere(steps, static step => YamlDocument.UsesAction(step, "actions/checkout")); + int pin = IndexWhere(steps, static step => RunText(step).Contains("./eng/ci/health/Set-CanarySdkVersion.ps1", StringComparison.Ordinal)); + int setup = IndexOf(steps, SetupAction); + int canary = IndexWhere(steps, static step => RunText(step).Contains("./eng/ci/health/Invoke-SdkCanary.ps1", StringComparison.Ordinal)); + + // global.json has rollForward: disable, so the composite action installs exactly the SDK the pin step wrote. + Assert.True(checkout >= 0 && checkout < pin && pin < setup && setup < canary, + $"The canary must check out, select the SDK, set up .NET, then run (indexes {checkout}, {pin}, {setup}, {canary})."); + Assert.Equal("sdk", YamlDocument.Scalar(steps[pin], "id")); + Assert.Equal("0", YamlDocument.Scalar(YamlDocument.Child(steps[checkout], "with"), "fetch-depth")); + Assert.Null(YamlDocument.Scalar(YamlDocument.Child(steps[setup], "with"), "cache")); + } + + [Fact] + public void Every_scheduled_health_dotnet_job_uses_the_composite_action() + { + // Same rule as WorkflowContractTests: a job whose run steps, or the repository scripts they start, run the .NET CLI + // installs the pinned SDK through the composite action first. + List dotnetJobs = []; + foreach (KeyValuePair job in YamlDocument.Load(GovernanceWorkflows.ScheduledHealth).Jobs) + { + IReadOnlyList steps = YamlDocument.Steps(job.Value); + int firstDotnet = IndexWhere(steps, static step => RunsDotnet(RunText(step))); + if (firstDotnet < 0) + { + continue; + } + + dotnetJobs.Add(job.Key); + int setup = IndexOf(steps, SetupAction); + Assert.True(setup >= 0 && setup < firstDotnet, + $"scheduled-health job {job.Key} runs the .NET CLI at step {firstDotnet} without first using {SetupAction}."); + } + + Assert.Equal(["audit", "canary", "test-repeat"], dotnetJobs); + } + + [Fact] + public void Bridge_drift_uses_the_toolchain_pins_of_the_native_job() + { + YamlMappingNode drift = YamlDocument.Load(GovernanceWorkflows.ScheduledHealth).Job("bridge-drift"); + Dictionary driftPins = BridgePins(drift); + Assert.Equal(["BRIDGE_VS_SDKVER", "BRIDGE_VS_TOOLSET"], driftPins.Keys.Order(StringComparer.Ordinal), StringComparer.Ordinal); + string run = RunText(Assert.Single(YamlDocument.Steps(drift), static step => YamlDocument.Scalar(step, "id") is "drift")); + Assert.Contains("--vs_toolset=$env:BRIDGE_VS_TOOLSET", run, StringComparison.Ordinal); + Assert.Contains("--vs_sdkver=$env:BRIDGE_VS_SDKVER", run, StringComparison.Ordinal); + + // Once the native job pins its toolchain, both jobs must name the same toolset and Windows SDK: a drift then means + // that today's pinned CI toolchain no longer rebuilds the released bridge. + YamlMappingNode native = YamlDocument.Load(PipelineWorkflow).Job("native"); + Dictionary nativePins = BridgePins(native); + string nativeText = string.Join('\n', YamlDocument.Steps(native).Select(RunText)); + if (nativePins.Count == 0) + { + Assert.True(!nativeText.Contains("vs_toolset", StringComparison.OrdinalIgnoreCase) && !nativeText.Contains("VsToolset", StringComparison.Ordinal), + "The native job pins its toolchain without BRIDGE_VS_* job variables: update scheduled-health.yml bridge-drift and this test together."); + return; + } + + Assert.Equal(nativePins.OrderBy(static pin => pin.Key, StringComparer.Ordinal), + driftPins.OrderBy(static pin => pin.Key, StringComparer.Ordinal)); + } + + [Fact] + public void Scheduled_health_uploads_diagnostics_under_their_reserved_names() + { + Dictionary expected = new(StringComparer.Ordinal) + { + ["health-sdk-canary"] = ("canary", "${{ !cancelled() }}", "14"), + ["health-test-repeat"] = ("test-repeat", "failure()", "14"), + ["health-bridge-drift"] = ("bridge-drift", "${{ !cancelled() }}", "30") + }; + + Dictionary actual = new(StringComparer.Ordinal); + foreach (KeyValuePair job in YamlDocument.Load(GovernanceWorkflows.ScheduledHealth).Jobs) + { + foreach (YamlMappingNode step in YamlDocument.Steps(job.Value)) + { + if (YamlDocument.UsesAction(step, "actions/upload-artifact")) + { + YamlNode? with = YamlDocument.Child(step, "with"); + actual[YamlDocument.Scalar(with, "name") ?? ""] = + (job.Key, YamlDocument.Scalar(step, "if") ?? "", YamlDocument.Scalar(with, "retention-days") ?? ""); + } + } + } + + Assert.Equal(expected.OrderBy(static item => item.Key, StringComparer.Ordinal), actual.OrderBy(static item => item.Key, StringComparer.Ordinal)); + } + + private static Dictionary BridgePins(YamlMappingNode job) + { + Dictionary pins = new(StringComparer.Ordinal); + YamlNode? env = YamlDocument.Child(job, "env"); + foreach (string key in YamlDocument.KeysOf(env)) + { + if (key.StartsWith("BRIDGE_VS_", StringComparison.Ordinal)) + { + pins[key] = YamlDocument.Scalar(env, key) ?? ""; + } + } + + return pins; + } + + private static string RunText(YamlMappingNode step) + { + return YamlDocument.Scalar(step, "run") ?? ""; + } + + private static int IndexWhere(IReadOnlyList steps, Func predicate) + { + for (int i = 0; i < steps.Count; i++) + { + if (predicate(steps[i])) + { + return i; + } + } + + return -1; + } + + /// Whether a run script, or a repository script it starts, runs the .NET CLI (comments ignored). + private static bool RunsDotnet(string run) + { + if (DotnetInvocation().IsMatch(StripComments(run))) + { + return true; + } + + foreach (Match script in ScriptReference().Matches(run)) + { + string path = RepositoryFile.FullPath(script.Groups["path"].Value); + if (File.Exists(path) && DotnetInvocation().IsMatch(StripComments(File.ReadAllText(path)))) + { + return true; + } + } + + return false; + } + + private static string StripComments(string script) + { + List lines = []; + foreach (string line in CommentBlock().Replace(script, "").ReplaceLineEndings("\n").Split('\n')) + { + if (!line.TrimStart().StartsWith('#')) + { + lines.Add(line); + } + } + + return string.Join('\n', lines); + } + + [GeneratedRegex(@"^\d{1,2} \d{1,2} \* \* [0-6]$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex WeeklyCron(); + + /// + /// A .NET CLI invocation: dotnet build ... (the WorkflowContractTests pattern), or the executable passed as a + /// quoted name, as the health scripts do through Invoke-NativeCommand -FilePath 'dotnet'. + /// + [GeneratedRegex(@"(?m)(?:^|[\s;(|{&])dotnet\s|(?(?:eng|tests)/[\w./-]+\.ps1)\b", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, + matchTimeoutMilliseconds: 1000)] + private static partial Regex ScriptReference(); + + [GeneratedRegex(@"<#.*?#>", RegexOptions.Singleline | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex CommentBlock(); +} diff --git a/tests/CheatEngine.SDK.Repository.Tests/Governance/HealthCheckCases.cs b/tests/CheatEngine.SDK.Repository.Tests/Governance/HealthCheckCases.cs new file mode 100644 index 00000000..f357bec8 --- /dev/null +++ b/tests/CheatEngine.SDK.Repository.Tests/Governance/HealthCheckCases.cs @@ -0,0 +1,229 @@ +namespace CheatEngine.SDK.Repository.Tests.Governance; + +/// +/// Vectors for the pure decisions of the scheduled health workflow (eng/ci/health/HealthCheck.psm1): release +/// tag selection, bridge drift classification, SDK canary selection, report parsing, link verdicts and the health +/// issue (audit register PR-CQ-55, PR-CQ-30, A21-25). +/// +internal static class HealthCheckCases +{ + public const string ModulePath = "eng/ci/health/HealthCheck.psm1"; + + public const string HashA = "da08c2ba03019da3a8c432ef061d5d6133fd2169ba3a6a8e9ac903353856d994"; + public const string HashB = "5c9923867989efcd256b643fdff61ed2fdfa1ee7133311bd89395e25d6d5cf77"; + public const string HashC = "039b03f62f57aa9d1ea20f006c9d917988bf0c23d166cb88c68ada30477e0d7c"; + + /// The v1.0.0 bridge fingerprint (shared-contracts §2.4). + public const string Fingerprint = + "8a63e00c7dd941212e7ef8c13d8c97f73142c5154bfbe5dbc5459e7131bb789b:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8"; + + public const string RunUrl = "https://github.com/CheatEngineNet/CheatEngine.SDK/actions/runs/123456789"; + + public const string Trx = """ + + + + + + + """; + + public const string VulnerableReport = """ + { + "version": 1, + "parameters": "--vulnerable --include-transitive", + "problems": [ { "project": "C:/repo/tests/X.Tests/X.Tests.csproj", "level": "warning", "text": "No assets file." } ], + "sources": [ "https://api.nuget.org/v3/index.json" ], + "projects": [ + { "path": "C:/repo/libs/A/A.csproj" }, + { + "path": "C:/repo/tests/B.Tests/B.Tests.csproj", + "frameworks": [ + { + "framework": "net10.0", + "topLevelPackages": [ + { "id": "Contoso.Direct", "requestedVersion": "1.0.0", "resolvedVersion": "1.0.0", + "vulnerabilities": [ { "severity": "High", "advisoryurl": "https://github.com/advisories/GHSA-aaaa-bbbb-cccc" } ] } + ], + "transitivePackages": [ + { "id": "Contoso.Transitive", "resolvedVersion": "2.1.0", + "vulnerabilities": [ + { "severity": "Low", "advisoryurl": "https://github.com/advisories/GHSA-1111-2222-3333" }, + { "severity": "Moderate", "advisoryurl": "https://github.com/advisories/GHSA-4444-5555-6666" } ] } + ] + } + ] + } + ] + } + """; + + public const string DeprecatedReport = """ + { + "version": 1, + "parameters": "--deprecated", + "sources": [ "https://api.nuget.org/v3/index.json" ], + "projects": [ + { + "path": "C:/repo/src/P/P.csproj", + "frameworks": [ + { + "framework": "net10.0", + "topLevelPackages": [ + { "id": "Contoso.Old", "requestedVersion": "3.0.0", "resolvedVersion": "3.0.0", + "deprecationReasons": [ "Legacy", "CriticalBugs" ], + "alternativePackage": { "id": "Contoso.New", "versionRange": ">= 4.0.0" } } + ] + } + ] + } + ] + } + """; + + public const string Solution = """ + + + + + + + + + + + + """; + + public const string Markdown = """ + # Links + + See [the NuGet docs](https://learn.microsoft.com/nuget/concepts/auditing-packages#running-nuget-audit-in-ci), again + https://learn.microsoft.com/nuget/concepts/auditing-packages#running-nuget-audit-in-ci, and . + A sentence ends with https://docs.zizmor.sh/usage/. + Internal: https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/SECURITY.md and + https://github.com/CheatEngineNet/CheatEngine.SDK/tree/main/docs are checked offline, but + https://github.com/CheatEngineNet/CheatEngine.SDK/security/advisories/new is requested. + Skipped: http://localhost:5000/x, https://www.example.com/a, https://api.nuget.org/v3-flatcontainer/cheatengine.sdk/{version}/x. + Templates: `https://raw.githubusercontent.com/CheatEngineNet/CheatEngine.SDK//` and + https://api.nuget.org/v3-flatcontainer//index.json are not links, nor is ``code with `https://in-code.invalid` ``. + + ```powershell + Invoke-WebRequest https://in-a-fence.invalid/never + ``` + + | Table | https://github.com/cheat-engine/cheat-engine | + """; + + public static readonly string NeedsAllSucceeded = """ + { "audit": { "result": "success", "outputs": {} }, "canary": { "result": "success", "outputs": {} }, + "links": { "result": "success", "outputs": { "broken": "false" } } } + """; + + public static readonly string NeedsOneFailed = """ + { "audit": { "result": "failure", "outputs": {} }, "canary": { "result": "success", "outputs": {} }, + "test-repeat": { "result": "cancelled", "outputs": {} } } + """; + + public static readonly string NeedsInjected = """ + { "evil|job\n| x": { "result": "success" }, "canary": { "result": "