diff --git a/.kiro/specs/deterministic-output/design.md b/.kiro/specs/deterministic-output/design.md
new file mode 100644
index 0000000..1c00791
--- /dev/null
+++ b/.kiro/specs/deterministic-output/design.md
@@ -0,0 +1,361 @@
+# Design Document: Deterministic Output
+
+## Overview
+
+This design ensures that OpenAPI specification outputs are deterministic across multiple runs. The key insight is that non-determinism in OpenAPI output typically comes from dictionary/collection iteration order, which varies based on insertion order or hash codes. By enforcing consistent ordering at serialization time, we guarantee identical output for identical input.
+
+The design also adds a "skip unchanged" feature to the merge tool, preventing unnecessary file writes when content hasn't changed.
+
+## Architecture
+
+The solution involves two main areas:
+
+1. **Ordering Enforcement**: Modify the serialization/output phase to sort collections before writing
+2. **Skip Unchanged**: Add file comparison logic to the merge tool CLI
+
+### Ordering Strategy
+
+Rather than modifying every place where collections are built, we'll implement ordering at the final output stage:
+
+1. **Source Generator**: Sort collections in `MergeOpenApiDocs` before serialization
+2. **Merger**: Sort collections in the `Merge` method before returning the result
+3. **Merge Tool**: Sort collections before writing (as a safety net)
+
+This approach minimizes code changes and ensures ordering regardless of how documents are constructed.
+
+### Ordering Rules
+
+| Collection | Ordering Rule |
+|------------|---------------|
+| Paths | Alphabetical by path string |
+| Schemas | Alphabetical by schema name |
+| Properties (within schemas) | Alphabetical by property name |
+| Tags | Alphabetical by tag name |
+| Tag Groups | Alphabetical by group name |
+| Tags within Tag Groups | Alphabetical by tag name |
+| Security Schemes | Alphabetical by scheme name |
+| Operations (within paths) | HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE |
+| Responses | Ascending by status code (numeric) |
+| Examples | Alphabetical by example name |
+| Servers | Preserve declaration/configuration order |
+
+## Components and Interfaces
+
+### New Component: OpenApiDocumentSorter
+
+A static utility class that sorts all collections in an OpenAPI document for deterministic output.
+
+```csharp
+namespace Oproto.Lambda.OpenApi.Merge;
+
+///
+/// Sorts OpenAPI document collections for deterministic output.
+///
+public static class OpenApiDocumentSorter
+{
+ ///
+ /// HTTP method ordering for operations within a path.
+ ///
+ private static readonly OperationType[] OperationOrder =
+ {
+ OperationType.Get,
+ OperationType.Put,
+ OperationType.Post,
+ OperationType.Delete,
+ OperationType.Options,
+ OperationType.Head,
+ OperationType.Patch,
+ OperationType.Trace
+ };
+
+ ///
+ /// Sorts all collections in the document for deterministic output.
+ /// Returns a new document with sorted collections.
+ ///
+ public static OpenApiDocument Sort(OpenApiDocument document);
+
+ ///
+ /// Sorts paths alphabetically.
+ ///
+ internal static OpenApiPaths SortPaths(OpenApiPaths paths);
+
+ ///
+ /// Sorts schemas alphabetically by name.
+ ///
+ internal static IDictionary SortSchemas(
+ IDictionary schemas);
+
+ ///
+ /// Sorts properties within a schema alphabetically.
+ ///
+ internal static OpenApiSchema SortSchemaProperties(OpenApiSchema schema);
+
+ ///
+ /// Sorts tags alphabetically by name.
+ ///
+ internal static IList SortTags(IList tags);
+
+ ///
+ /// Sorts security schemes alphabetically by name.
+ ///
+ internal static IDictionary SortSecuritySchemes(
+ IDictionary schemes);
+
+ ///
+ /// Sorts operations within a path by HTTP method order.
+ ///
+ internal static IDictionary SortOperations(
+ IDictionary operations);
+
+ ///
+ /// Sorts responses by status code (ascending).
+ ///
+ internal static OpenApiResponses SortResponses(OpenApiResponses responses);
+
+ ///
+ /// Sorts examples alphabetically by name.
+ ///
+ internal static IDictionary SortExamples(
+ IDictionary examples);
+
+ ///
+ /// Sorts tag groups alphabetically by name, and tags within groups alphabetically.
+ ///
+ internal static void SortTagGroups(OpenApiDocument document);
+}
+```
+
+### Modified Component: OpenApiMerger
+
+The `Merge` method will call `OpenApiDocumentSorter.Sort()` before returning the result.
+
+```csharp
+public MergeResult Merge(
+ MergeConfiguration config,
+ IEnumerable<(SourceConfiguration Source, OpenApiDocument Document)> documents)
+{
+ // ... existing merge logic ...
+
+ // Sort for deterministic output
+ var sortedDocument = OpenApiDocumentSorter.Sort(mergedDocument);
+
+ return new MergeResult(sortedDocument, warnings, success: true);
+}
+```
+
+### Modified Component: MergeCommand
+
+Add `--force` flag and skip-unchanged logic.
+
+```csharp
+// New option
+var forceOption = new Option(
+ new[] { "-f", "--force" },
+ "Force write output even if unchanged");
+
+// Modified write logic
+private static async Task WriteOpenApiDocumentAsync(
+ OpenApiDocument document,
+ string outputPath,
+ bool verbose,
+ bool force)
+{
+ // ... validation ...
+
+ var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+
+ // Check if file exists and content matches
+ if (!force && File.Exists(outputPath))
+ {
+ var existingContent = await File.ReadAllTextAsync(outputPath);
+ if (existingContent == json)
+ {
+ if (verbose)
+ {
+ Console.WriteLine($"Output unchanged, skipping write: {outputPath}");
+ }
+ return false; // Indicates file was not written
+ }
+ }
+
+ await File.WriteAllTextAsync(outputPath, json);
+ return true; // Indicates file was written
+}
+```
+
+### Modified Component: OpenApiSpecGenerator (Source Generator)
+
+The source generator will sort the merged document before serialization.
+
+```csharp
+private OpenApiDocument MergeOpenApiDocs(ImmutableArray docs, Compilation? compilation)
+{
+ // ... existing merge logic ...
+
+ // Sort for deterministic output before returning
+ return SortDocument(mergedDoc);
+}
+
+///
+/// Sorts all collections in the document for deterministic output.
+///
+private static OpenApiDocument SortDocument(OpenApiDocument document)
+{
+ // Sort paths
+ if (document.Paths != null && document.Paths.Count > 0)
+ {
+ var sortedPaths = new OpenApiPaths();
+ foreach (var path in document.Paths.OrderBy(p => p.Key, StringComparer.Ordinal))
+ {
+ sortedPaths[path.Key] = SortPathItem(path.Value);
+ }
+ document.Paths = sortedPaths;
+ }
+
+ // Sort schemas
+ if (document.Components?.Schemas != null && document.Components.Schemas.Count > 0)
+ {
+ var sortedSchemas = new Dictionary();
+ foreach (var schema in document.Components.Schemas.OrderBy(s => s.Key, StringComparer.Ordinal))
+ {
+ sortedSchemas[schema.Key] = SortSchemaProperties(schema.Value);
+ }
+ document.Components.Schemas = sortedSchemas;
+ }
+
+ // Sort tags
+ if (document.Tags != null && document.Tags.Count > 0)
+ {
+ document.Tags = document.Tags.OrderBy(t => t.Name, StringComparer.Ordinal).ToList();
+ }
+
+ // Sort security schemes
+ if (document.Components?.SecuritySchemes != null && document.Components.SecuritySchemes.Count > 0)
+ {
+ var sortedSchemes = new Dictionary();
+ foreach (var scheme in document.Components.SecuritySchemes.OrderBy(s => s.Key, StringComparer.Ordinal))
+ {
+ sortedSchemes[scheme.Key] = scheme.Value;
+ }
+ document.Components.SecuritySchemes = sortedSchemes;
+ }
+
+ // Sort tag groups
+ SortTagGroups(document);
+
+ return document;
+}
+```
+
+## Data Models
+
+No new data models are required. The existing OpenAPI models from Microsoft.OpenApi are used.
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: Path Ordering
+
+*For any* OpenAPI document with multiple paths, after sorting, the paths SHALL appear in alphabetical order by path string using ordinal comparison.
+
+**Validates: Requirements 1.1, 1.2**
+
+### Property 2: Schema Ordering
+
+*For any* OpenAPI document with multiple schemas, after sorting, the schemas SHALL appear in alphabetical order by schema name using ordinal comparison.
+
+**Validates: Requirements 2.1, 2.2**
+
+### Property 3: Property Ordering Within Schemas
+
+*For any* OpenAPI schema with multiple properties, after sorting, the properties SHALL appear in alphabetical order by property name using ordinal comparison.
+
+**Validates: Requirements 3.1, 3.2**
+
+### Property 4: Tag Ordering
+
+*For any* OpenAPI document with multiple tags, after sorting, the tags SHALL appear in alphabetical order by tag name using ordinal comparison.
+
+**Validates: Requirements 4.1, 4.2**
+
+### Property 5: Tag Group Ordering
+
+*For any* OpenAPI document with tag groups, after sorting, the tag groups SHALL appear in alphabetical order by group name, and tags within each group SHALL appear in alphabetical order.
+
+**Validates: Requirements 5.1, 5.2, 5.3**
+
+### Property 6: Security Scheme Ordering
+
+*For any* OpenAPI document with multiple security schemes, after sorting, the security schemes SHALL appear in alphabetical order by scheme name using ordinal comparison.
+
+**Validates: Requirements 7.1, 7.2**
+
+### Property 7: Operation Ordering
+
+*For any* path with multiple operations, after sorting, the operations SHALL appear in HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE.
+
+**Validates: Requirements 8.1, 8.2**
+
+### Property 8: Response Ordering
+
+*For any* operation with multiple responses, after sorting, the responses SHALL appear in ascending order by status code (treating status codes as integers, with "default" sorted last).
+
+**Validates: Requirements 9.1, 9.2**
+
+### Property 9: Example Ordering
+
+*For any* media type with multiple examples, after sorting, the examples SHALL appear in alphabetical order by example name using ordinal comparison.
+
+**Validates: Requirements 11.1, 11.2**
+
+### Property 10: Output Idempotence (Round-Trip)
+
+*For any* valid OpenAPI document, serializing the document, then deserializing and re-serializing SHALL produce identical JSON output.
+
+**Validates: Requirements 1.3, 2.3, 3.3, 4.3**
+
+### Property 11: Server Order Preservation
+
+*For any* OpenAPI document with servers, the servers SHALL appear in the same order as they were declared in source code or configuration.
+
+**Validates: Requirements 6.1, 6.2**
+
+## Error Handling
+
+| Scenario | Handling |
+|----------|----------|
+| Null document passed to sorter | Return null or throw ArgumentNullException |
+| Empty collections | Return empty sorted collection (no-op) |
+| File read error during skip-unchanged check | Log warning and proceed with write |
+| File write permission denied | Propagate exception with clear message |
+
+## Testing Strategy
+
+### Property-Based Testing
+
+We will use FsCheck for property-based testing, consistent with the existing test suite. Each correctness property will be implemented as a property-based test with minimum 100 iterations.
+
+**Test Configuration:**
+- Framework: xUnit with FsCheck.Xunit
+- Minimum iterations: 100 per property
+- Generators: Custom generators for OpenAPI documents with various collection sizes
+
+### Unit Tests
+
+Unit tests will cover:
+- Edge cases (empty collections, single items, null values)
+- Skip-unchanged file comparison logic
+- Force flag behavior
+- Specific ordering edge cases (e.g., "default" response sorting)
+
+### Test File Organization
+
+```
+Oproto.Lambda.OpenApi.Merge.Tests/
+ DeterministicOutputPropertyTests.cs # Property tests for sorter
+ OpenApiDocumentSorterTests.cs # Unit tests for sorter
+
+Oproto.Lambda.OpenApi.Tests/
+ DeterministicGeneratorPropertyTests.cs # Property tests for source generator ordering
+```
diff --git a/.kiro/specs/deterministic-output/requirements.md b/.kiro/specs/deterministic-output/requirements.md
new file mode 100644
index 0000000..1ec6d09
--- /dev/null
+++ b/.kiro/specs/deterministic-output/requirements.md
@@ -0,0 +1,124 @@
+# Requirements Document
+
+## Introduction
+
+This feature ensures that OpenAPI specification outputs are deterministic - meaning that if no changes are made to the source APIs, the generated OpenAPI JSON files will always be identical. This is critical for CI/CD pipelines, version control, and avoiding unnecessary file changes. Additionally, this feature adds the ability to skip writing output files when they match existing files, with an optional override flag.
+
+## Glossary
+
+- **Source_Generator**: The Roslyn-based source generator (`OpenApiSpecGenerator`) that produces OpenAPI specifications from Lambda function attributes at compile time.
+- **Merger**: The `OpenApiMerger` component that combines multiple OpenAPI documents into a single unified specification.
+- **Deterministic_Output**: Output that is identical across multiple runs when given the same input, regardless of execution order or timing.
+- **Schema_Ordering**: The order in which schemas appear in the `components/schemas` section of an OpenAPI document.
+- **Path_Ordering**: The order in which paths appear in the `paths` section of an OpenAPI document.
+- **Tag_Ordering**: The order in which tags appear in the `tags` section of an OpenAPI document.
+- **Property_Ordering**: The order in which properties appear within a schema definition.
+- **Skip_Unchanged**: A feature that prevents writing output files when the new content matches the existing file content.
+
+## Requirements
+
+### Requirement 1: Deterministic Path Ordering
+
+**User Story:** As a developer, I want paths in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator processes Lambda functions, THE Source_Generator SHALL output paths in alphabetical order by path string.
+2. WHEN the Merger combines multiple OpenAPI documents, THE Merger SHALL output paths in alphabetical order by path string.
+3. FOR ALL valid OpenAPI documents, generating then re-generating without changes SHALL produce identical path ordering.
+
+### Requirement 2: Deterministic Schema Ordering
+
+**User Story:** As a developer, I want schemas in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator processes types, THE Source_Generator SHALL output schemas in alphabetical order by schema name.
+2. WHEN the Merger combines schemas from multiple sources, THE Merger SHALL output schemas in alphabetical order by schema name.
+3. FOR ALL valid OpenAPI documents with schemas, generating then re-generating without changes SHALL produce identical schema ordering.
+
+### Requirement 3: Deterministic Property Ordering Within Schemas
+
+**User Story:** As a developer, I want properties within schemas to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator generates schema properties, THE Source_Generator SHALL output properties in alphabetical order by property name.
+2. WHEN the Merger clones schemas, THE Merger SHALL preserve or establish alphabetical ordering of properties.
+3. FOR ALL valid schemas with properties, generating then re-generating without changes SHALL produce identical property ordering.
+
+### Requirement 4: Deterministic Tag Ordering
+
+**User Story:** As a developer, I want tags in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator collects tags from operations, THE Source_Generator SHALL output tags in alphabetical order by tag name.
+2. WHEN the Merger combines tags from multiple sources, THE Merger SHALL output tags in alphabetical order by tag name.
+3. FOR ALL valid OpenAPI documents with tags, generating then re-generating without changes SHALL produce identical tag ordering.
+
+### Requirement 5: Deterministic Tag Group Ordering
+
+**User Story:** As a developer, I want tag groups in the x-tagGroups extension to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator processes OpenApiTagGroup attributes, THE Source_Generator SHALL output tag groups in alphabetical order by group name.
+2. WHEN the Merger combines tag groups from multiple sources, THE Merger SHALL output tag groups in alphabetical order by group name.
+3. WHEN tag groups contain tags, THE Merger SHALL output tags within each group in alphabetical order.
+
+### Requirement 6: Deterministic Server Ordering
+
+**User Story:** As a developer, I want servers in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator processes OpenApiServer attributes, THE Source_Generator SHALL output servers in the order they are declared in source code.
+2. WHEN the Merger combines servers from configuration, THE Merger SHALL output servers in the order they appear in the configuration.
+
+### Requirement 7: Deterministic Security Scheme Ordering
+
+**User Story:** As a developer, I want security schemes in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator processes security schemes, THE Source_Generator SHALL output security schemes in alphabetical order by scheme name.
+2. WHEN the Merger combines security schemes from multiple sources, THE Merger SHALL output security schemes in alphabetical order by scheme name.
+
+### Requirement 8: Deterministic Operation Ordering Within Paths
+
+**User Story:** As a developer, I want operations within each path to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator generates operations for a path, THE Source_Generator SHALL output operations in a consistent order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE).
+2. WHEN the Merger combines operations for a path, THE Merger SHALL output operations in the same consistent order.
+
+### Requirement 9: Deterministic Response Ordering
+
+**User Story:** As a developer, I want responses within operations to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator generates responses for an operation, THE Source_Generator SHALL output responses in ascending order by status code.
+2. WHEN the Merger clones responses, THE Merger SHALL preserve or establish ascending order by status code.
+
+### Requirement 10: Skip Unchanged Output Files
+
+**User Story:** As a developer, I want the build process to skip writing output files when they haven't changed, so that I don't get unnecessary file modifications in version control.
+
+#### Acceptance Criteria
+
+1. WHEN the Merge_Tool generates output and an existing file matches the new content, THE Merge_Tool SHALL skip writing the file by default.
+2. WHEN the Merge_Tool skips writing a file, THE Merge_Tool SHALL log a message indicating the file was unchanged.
+3. WHEN the --force flag is provided, THE Merge_Tool SHALL write the output file regardless of whether it matches existing content.
+4. IF the output file does not exist, THEN THE Merge_Tool SHALL write the file regardless of any flags.
+
+### Requirement 11: Deterministic Example Ordering
+
+**User Story:** As a developer, I want examples in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes.
+
+#### Acceptance Criteria
+
+1. WHEN the Source_Generator generates examples for a media type, THE Source_Generator SHALL output examples in alphabetical order by example name.
+2. WHEN the Merger clones examples, THE Merger SHALL preserve or establish alphabetical ordering of examples.
diff --git a/.kiro/specs/deterministic-output/tasks.md b/.kiro/specs/deterministic-output/tasks.md
new file mode 100644
index 0000000..600dad0
--- /dev/null
+++ b/.kiro/specs/deterministic-output/tasks.md
@@ -0,0 +1,130 @@
+# Implementation Plan: Deterministic Output
+
+## Overview
+
+This implementation adds deterministic ordering to OpenAPI output and a skip-unchanged feature to the merge tool. The approach is to create a central sorting utility, integrate it into both the merger and source generator, and add file comparison logic to the CLI.
+
+## Tasks
+
+- [x] 1. Create OpenApiDocumentSorter utility class
+ - [x] 1.1 Create OpenApiDocumentSorter.cs in Oproto.Lambda.OpenApi.Merge
+ - Implement `Sort(OpenApiDocument)` method as entry point
+ - Implement `SortPaths()` for alphabetical path ordering
+ - Implement `SortSchemas()` for alphabetical schema ordering
+ - Implement `SortSchemaProperties()` for alphabetical property ordering within schemas
+ - Implement `SortTags()` for alphabetical tag ordering
+ - Implement `SortSecuritySchemes()` for alphabetical security scheme ordering
+ - Implement `SortOperations()` for HTTP method order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE)
+ - Implement `SortResponses()` for ascending status code order
+ - Implement `SortExamples()` for alphabetical example ordering
+ - Implement `SortTagGroups()` for alphabetical tag group and tag-within-group ordering
+ - Use `StringComparer.Ordinal` for consistent cross-platform ordering
+ - _Requirements: 1.1, 1.2, 2.1, 2.2, 3.1, 3.2, 4.1, 4.2, 5.1, 5.2, 5.3, 7.1, 7.2, 8.1, 8.2, 9.1, 9.2, 11.1, 11.2_
+
+ - [x] 1.2 Write property test for path ordering
+ - **Property 1: Path Ordering**
+ - **Validates: Requirements 1.1, 1.2**
+
+ - [x] 1.3 Write property test for schema ordering
+ - **Property 2: Schema Ordering**
+ - **Validates: Requirements 2.1, 2.2**
+
+ - [x] 1.4 Write property test for property ordering within schemas
+ - **Property 3: Property Ordering Within Schemas**
+ - **Validates: Requirements 3.1, 3.2**
+
+ - [x] 1.5 Write property test for tag ordering
+ - **Property 4: Tag Ordering**
+ - **Validates: Requirements 4.1, 4.2**
+
+ - [x] 1.6 Write property test for tag group ordering
+ - **Property 5: Tag Group Ordering**
+ - **Validates: Requirements 5.1, 5.2, 5.3**
+
+ - [x] 1.7 Write property test for security scheme ordering
+ - **Property 6: Security Scheme Ordering**
+ - **Validates: Requirements 7.1, 7.2**
+
+ - [x] 1.8 Write property test for operation ordering
+ - **Property 7: Operation Ordering**
+ - **Validates: Requirements 8.1, 8.2**
+
+ - [x] 1.9 Write property test for response ordering
+ - **Property 8: Response Ordering**
+ - **Validates: Requirements 9.1, 9.2**
+
+ - [x] 1.10 Write property test for example ordering
+ - **Property 9: Example Ordering**
+ - **Validates: Requirements 11.1, 11.2**
+
+- [x] 2. Integrate sorter into OpenApiMerger
+ - [x] 2.1 Modify OpenApiMerger.Merge() to call OpenApiDocumentSorter.Sort()
+ - Call Sort() on the merged document before returning
+ - Ensure sorting happens after all merge operations complete
+ - _Requirements: 1.2, 2.2, 3.2, 4.2, 5.2, 7.2, 8.2, 9.2, 11.2_
+
+ - [x] 2.2 Write property test for output idempotence
+ - **Property 10: Output Idempotence (Round-Trip)**
+ - **Validates: Requirements 1.3, 2.3, 3.3, 4.3**
+
+- [x] 3. Checkpoint - Ensure all merger tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 4. Integrate sorter into Source Generator
+ - [x] 4.1 Add sorting logic to OpenApiSpecGenerator.MergeOpenApiDocs()
+ - Implement sorting methods directly in the source generator (cannot reference Merge project)
+ - Sort paths alphabetically
+ - Sort schemas alphabetically
+ - Sort properties within schemas alphabetically
+ - Sort tags alphabetically
+ - Sort security schemes alphabetically
+ - Sort operations by HTTP method order
+ - Sort responses by status code
+ - Sort examples alphabetically
+ - Sort tag groups and tags within groups alphabetically
+ - _Requirements: 1.1, 2.1, 3.1, 4.1, 5.1, 7.1, 8.1, 9.1, 11.1_
+
+ - [x] 4.2 Write property test for generator deterministic output
+ - Test that generating the same source twice produces identical output
+ - **Validates: Requirements 1.1, 2.1, 3.1, 4.1, 5.1, 7.1, 8.1, 9.1, 11.1**
+
+- [x] 5. Checkpoint - Ensure all generator tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 6. Add skip-unchanged feature to merge tool
+ - [x] 6.1 Add --force flag to MergeCommand
+ - Add `-f, --force` option to command definition
+ - Pass force flag to write method
+ - _Requirements: 10.3_
+
+ - [x] 6.2 Implement skip-unchanged logic in WriteOpenApiDocumentAsync
+ - Read existing file content if file exists
+ - Compare with new content
+ - Skip write if content matches and force is false
+ - Log message when skipping
+ - Always write if file doesn't exist
+ - _Requirements: 10.1, 10.2, 10.4_
+
+ - [x] 6.3 Write unit tests for skip-unchanged feature
+ - Test file is skipped when content matches
+ - Test file is written when content differs
+ - Test file is written when --force is used
+ - Test file is written when it doesn't exist
+ - Test log message is output when skipping
+ - _Requirements: 10.1, 10.2, 10.3, 10.4_
+
+- [x] 7. Add server order preservation test
+ - [x] 7.1 Write property test for server order preservation
+ - **Property 11: Server Order Preservation**
+ - **Validates: Requirements 6.1, 6.2**
+
+- [x] 8. Final checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+## Notes
+
+- All test tasks are required for comprehensive coverage
+- The source generator cannot reference the Merge project, so sorting logic must be duplicated
+- Use `StringComparer.Ordinal` for consistent cross-platform string ordering
+- Property tests should use FsCheck with minimum 100 iterations
+- Each property test should reference its design document property number
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87ca741..40dbe67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+- **Deterministic Output**
+ - OpenAPI output is now fully deterministic across multiple runs with identical input
+ - Paths sorted alphabetically by path string
+ - Schemas sorted alphabetically by schema name
+ - Properties within schemas sorted alphabetically by property name
+ - Tags sorted alphabetically by tag name
+ - Tag groups sorted alphabetically by group name, with tags within groups also sorted
+ - Security schemes sorted alphabetically by scheme name
+ - Operations within paths sorted by HTTP method order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE)
+ - Responses sorted by status code in ascending order
+ - Examples sorted alphabetically by example name
+ - Server order preserved as declared in source code or configuration
+
+- **Skip Unchanged Output (Merge Tool)**
+ - Merge tool now skips writing output files when content matches existing file
+ - New `--force` (`-f`) flag to override skip behavior and always write output
+ - Verbose mode logs when files are skipped due to unchanged content
+
+### Changed
+
+- `OpenApiMerger.Merge()` now returns sorted documents for deterministic output
+- Source generator now sorts all collections before serialization
+
## [1.2.0] - 2025-12-22
### Added
diff --git a/Oproto.Lambda.OpenApi.Merge.Tests/DeterministicOutputPropertyTests.cs b/Oproto.Lambda.OpenApi.Merge.Tests/DeterministicOutputPropertyTests.cs
new file mode 100644
index 0000000..e095e5b
--- /dev/null
+++ b/Oproto.Lambda.OpenApi.Merge.Tests/DeterministicOutputPropertyTests.cs
@@ -0,0 +1,866 @@
+using FsCheck;
+using FsCheck.Xunit;
+using Microsoft.OpenApi;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Extensions;
+using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi.Readers;
+using Oproto.Lambda.OpenApi.Merge;
+
+namespace Oproto.Lambda.OpenApi.Merge.Tests;
+
+///
+/// Property-based tests for OpenApiDocumentSorter deterministic output.
+///
+public class DeterministicOutputPropertyTests
+{
+ ///
+ /// Generators for deterministic output test data.
+ ///
+ private static class SorterGenerators
+ {
+ public static Gen PathGen()
+ {
+ return from segmentCount in Gen.Choose(1, 3)
+ from segments in Gen.ListOf(segmentCount, PathSegmentGen())
+ let path = "/" + string.Join("/", segments)
+ select path;
+ }
+
+ public static Gen PathSegmentGen()
+ {
+ return Gen.OneOf(
+ Gen.Elements("users", "products", "orders", "items", "api", "v1", "v2", "admin", "auth", "config"),
+ Gen.Elements("{id}", "{userId}", "{productId}", "{orderId}")
+ );
+ }
+
+ public static Gen SchemaNameGen()
+ {
+ return Gen.Elements("User", "Product", "Order", "Item", "Response", "Request", "Data", "Result",
+ "Customer", "Invoice", "Payment", "Address", "Contact", "Category");
+ }
+
+ public static Gen PropertyNameGen()
+ {
+ return Gen.Elements("id", "name", "value", "count", "status", "email", "phone", "address",
+ "createdAt", "updatedAt", "description", "price", "quantity", "total");
+ }
+
+ public static Gen TagNameGen()
+ {
+ return Gen.Elements("Users", "Products", "Orders", "Items", "Admin", "Auth", "Config",
+ "Inventory", "Payments", "Reports", "Analytics", "Settings");
+ }
+
+ public static Gen SecuritySchemeNameGen()
+ {
+ return Gen.Elements("apiKey", "bearer", "oauth2", "basic", "openIdConnect",
+ "customAuth", "jwt", "session", "token");
+ }
+
+ public static Gen ExampleNameGen()
+ {
+ return Gen.Elements("success", "error", "notFound", "created", "updated", "deleted",
+ "validRequest", "invalidRequest", "emptyResponse", "fullResponse");
+ }
+
+ public static Gen TagGroupNameGen()
+ {
+ return Gen.Elements("User Management", "Product Catalog", "Order Processing",
+ "Administration", "Authentication", "Configuration", "Analytics");
+ }
+
+ public static Gen ServerUrlGen()
+ {
+ return Gen.Elements(
+ "https://api.example.com/v1",
+ "https://staging.example.com/v1",
+ "https://dev.example.com/v1",
+ "https://api.production.com",
+ "https://api.test.com",
+ "https://localhost:5000",
+ "https://api.internal.com/v2",
+ "https://gateway.example.com");
+ }
+
+
+ public static Gen DocumentWithPathsGen()
+ {
+ return from pathCount in Gen.Choose(2, 5)
+ from paths in Gen.ListOf(pathCount, PathGen())
+ let uniquePaths = paths.Distinct().ToList()
+ where uniquePaths.Count >= 2
+ select CreateDocumentWithPaths(uniquePaths);
+ }
+
+ public static Gen DocumentWithSchemasGen()
+ {
+ return from schemaCount in Gen.Choose(2, 5)
+ from schemaNames in Gen.ListOf(schemaCount, SchemaNameGen())
+ let uniqueSchemas = schemaNames.Distinct().ToList()
+ where uniqueSchemas.Count >= 2
+ select CreateDocumentWithSchemas(uniqueSchemas);
+ }
+
+ public static Gen DocumentWithTagsGen()
+ {
+ return from tagCount in Gen.Choose(2, 5)
+ from tagNames in Gen.ListOf(tagCount, TagNameGen())
+ let uniqueTags = tagNames.Distinct().ToList()
+ where uniqueTags.Count >= 2
+ select CreateDocumentWithTags(uniqueTags);
+ }
+
+ public static Gen DocumentWithSecuritySchemesGen()
+ {
+ return from schemeCount in Gen.Choose(2, 4)
+ from schemeNames in Gen.ListOf(schemeCount, SecuritySchemeNameGen())
+ let uniqueSchemes = schemeNames.Distinct().ToList()
+ where uniqueSchemes.Count >= 2
+ select CreateDocumentWithSecuritySchemes(uniqueSchemes);
+ }
+
+ public static Gen DocumentWithTagGroupsGen()
+ {
+ return from groupCount in Gen.Choose(2, 4)
+ from groupNames in Gen.ListOf(groupCount, TagGroupNameGen())
+ from tagCount in Gen.Choose(2, 4)
+ from tagNames in Gen.ListOf(tagCount, TagNameGen())
+ let uniqueGroups = groupNames.Distinct().ToList()
+ let uniqueTags = tagNames.Distinct().ToList()
+ where uniqueGroups.Count >= 2 && uniqueTags.Count >= 2
+ select CreateDocumentWithTagGroups(uniqueGroups, uniqueTags);
+ }
+
+ public static Gen DocumentWithOperationsGen()
+ {
+ return from opCount in Gen.Choose(2, 5)
+ from opTypes in Gen.ListOf(opCount, Gen.Elements(
+ OperationType.Get, OperationType.Put, OperationType.Post,
+ OperationType.Delete, OperationType.Patch, OperationType.Options))
+ let uniqueOps = opTypes.Distinct().ToList()
+ where uniqueOps.Count >= 2
+ select CreateDocumentWithOperations(uniqueOps);
+ }
+
+ public static Gen DocumentWithResponsesGen()
+ {
+ return from responseCount in Gen.Choose(2, 4)
+ from statusCodes in Gen.ListOf(responseCount, Gen.Elements("200", "201", "400", "404", "500", "default"))
+ let uniqueCodes = statusCodes.Distinct().ToList()
+ where uniqueCodes.Count >= 2
+ select CreateDocumentWithResponses(uniqueCodes);
+ }
+
+ public static Gen DocumentWithExamplesGen()
+ {
+ return from exampleCount in Gen.Choose(2, 4)
+ from exampleNames in Gen.ListOf(exampleCount, ExampleNameGen())
+ let uniqueExamples = exampleNames.Distinct().ToList()
+ where uniqueExamples.Count >= 2
+ select CreateDocumentWithExamples(uniqueExamples);
+ }
+
+ public static Gen DocumentWithServersGen()
+ {
+ return from serverCount in Gen.Choose(2, 4)
+ from serverUrls in Gen.ListOf(serverCount, ServerUrlGen())
+ let uniqueServers = serverUrls.Distinct().ToList()
+ where uniqueServers.Count >= 2
+ select CreateDocumentWithServers(uniqueServers);
+ }
+
+ public static Gen SchemaWithPropertiesGen()
+ {
+ return from propCount in Gen.Choose(2, 5)
+ from propNames in Gen.ListOf(propCount, PropertyNameGen())
+ let uniqueProps = propNames.Distinct().ToList()
+ where uniqueProps.Count >= 2
+ select CreateSchemaWithProperties(uniqueProps);
+ }
+
+ ///
+ /// Generates a comprehensive OpenAPI document with multiple paths, schemas, tags, and operations
+ /// for testing round-trip idempotence.
+ ///
+ public static Gen ComprehensiveDocumentGen()
+ {
+ return from pathCount in Gen.Choose(2, 4)
+ from paths in Gen.ListOf(pathCount, PathGen())
+ from schemaCount in Gen.Choose(2, 4)
+ from schemaNames in Gen.ListOf(schemaCount, SchemaNameGen())
+ from tagCount in Gen.Choose(2, 4)
+ from tagNames in Gen.ListOf(tagCount, TagNameGen())
+ from propCount in Gen.Choose(2, 4)
+ from propNames in Gen.ListOf(propCount, PropertyNameGen())
+ let uniquePaths = paths.Distinct().ToList()
+ let uniqueSchemas = schemaNames.Distinct().ToList()
+ let uniqueTags = tagNames.Distinct().ToList()
+ let uniqueProps = propNames.Distinct().ToList()
+ where uniquePaths.Count >= 2 && uniqueSchemas.Count >= 2 && uniqueTags.Count >= 2 && uniqueProps.Count >= 2
+ select CreateComprehensiveDocument(uniquePaths, uniqueSchemas, uniqueTags, uniqueProps);
+ }
+
+
+ private static OpenApiDocument CreateDocumentWithPaths(List paths)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths()
+ };
+
+ foreach (var path in paths)
+ {
+ doc.Paths[path] = new OpenApiPathItem
+ {
+ Operations = new Dictionary
+ {
+ [OperationType.Get] = new OpenApiOperation
+ {
+ OperationId = "get" + path.Replace("/", "_").Replace("{", "").Replace("}", ""),
+ Summary = $"Get {path}"
+ }
+ }
+ };
+ }
+
+ return doc;
+ }
+
+ private static OpenApiDocument CreateDocumentWithSchemas(List schemaNames)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths(),
+ Components = new OpenApiComponents
+ {
+ Schemas = new Dictionary()
+ }
+ };
+
+ foreach (var schemaName in schemaNames)
+ {
+ doc.Components.Schemas[schemaName] = new OpenApiSchema
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["id"] = new OpenApiSchema { Type = "string" },
+ ["name"] = new OpenApiSchema { Type = "string" }
+ }
+ };
+ }
+
+ return doc;
+ }
+
+ private static OpenApiDocument CreateDocumentWithTags(List tagNames)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths(),
+ Tags = new List()
+ };
+
+ foreach (var tagName in tagNames)
+ {
+ doc.Tags.Add(new OpenApiTag { Name = tagName, Description = $"Description for {tagName}" });
+ }
+
+ return doc;
+ }
+
+ private static OpenApiDocument CreateDocumentWithSecuritySchemes(List schemeNames)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths(),
+ Components = new OpenApiComponents
+ {
+ SecuritySchemes = new Dictionary()
+ }
+ };
+
+ foreach (var schemeName in schemeNames)
+ {
+ doc.Components.SecuritySchemes[schemeName] = new OpenApiSecurityScheme
+ {
+ Type = SecuritySchemeType.ApiKey,
+ Name = schemeName,
+ In = ParameterLocation.Header,
+ Description = $"Security scheme {schemeName}"
+ };
+ }
+
+ return doc;
+ }
+
+
+ private static OpenApiDocument CreateDocumentWithTagGroups(List groupNames, List tagNames)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths(),
+ Extensions = new Dictionary()
+ };
+
+ var tagGroupsArray = new OpenApiArray();
+ var tagIndex = 0;
+ foreach (var groupName in groupNames)
+ {
+ var tagsArray = new OpenApiArray();
+ // Assign some tags to each group
+ var tagsForGroup = tagNames.Skip(tagIndex % tagNames.Count).Take(2).ToList();
+ foreach (var tag in tagsForGroup)
+ {
+ tagsArray.Add(new OpenApiString(tag));
+ }
+ tagIndex++;
+
+ var groupObject = new OpenApiObject
+ {
+ ["name"] = new OpenApiString(groupName),
+ ["tags"] = tagsArray
+ };
+ tagGroupsArray.Add(groupObject);
+ }
+
+ doc.Extensions["x-tagGroups"] = tagGroupsArray;
+ return doc;
+ }
+
+ private static OpenApiDocument CreateDocumentWithOperations(List operationTypes)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths()
+ };
+
+ var operations = new Dictionary();
+ foreach (var opType in operationTypes)
+ {
+ operations[opType] = new OpenApiOperation
+ {
+ OperationId = opType.ToString().ToLower() + "Resource",
+ Summary = $"{opType} resource"
+ };
+ }
+
+ doc.Paths["/resource"] = new OpenApiPathItem { Operations = operations };
+ return doc;
+ }
+
+ private static OpenApiDocument CreateDocumentWithResponses(List statusCodes)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths()
+ };
+
+ var responses = new OpenApiResponses();
+ foreach (var code in statusCodes)
+ {
+ responses[code] = new OpenApiResponse { Description = $"Response {code}" };
+ }
+
+ doc.Paths["/resource"] = new OpenApiPathItem
+ {
+ Operations = new Dictionary
+ {
+ [OperationType.Get] = new OpenApiOperation
+ {
+ OperationId = "getResource",
+ Responses = responses
+ }
+ }
+ };
+
+ return doc;
+ }
+
+ private static OpenApiDocument CreateDocumentWithExamples(List exampleNames)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths()
+ };
+
+ var examples = new Dictionary();
+ foreach (var name in exampleNames)
+ {
+ examples[name] = new OpenApiExample
+ {
+ Summary = $"Example {name}",
+ Value = new OpenApiString($"value for {name}")
+ };
+ }
+
+ doc.Paths["/resource"] = new OpenApiPathItem
+ {
+ Operations = new Dictionary
+ {
+ [OperationType.Get] = new OpenApiOperation
+ {
+ OperationId = "getResource",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "Success",
+ Content = new Dictionary
+ {
+ ["application/json"] = new OpenApiMediaType
+ {
+ Examples = examples
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ return doc;
+ }
+
+ private static OpenApiDocument CreateDocumentWithServers(List serverUrls)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths(),
+ Servers = new List()
+ };
+
+ foreach (var url in serverUrls)
+ {
+ doc.Servers.Add(new OpenApiServer
+ {
+ Url = url,
+ Description = $"Server at {url}"
+ });
+ }
+
+ return doc;
+ }
+
+ private static OpenApiSchema CreateSchemaWithProperties(List propertyNames)
+ {
+ var schema = new OpenApiSchema
+ {
+ Type = "object",
+ Properties = new Dictionary()
+ };
+
+ foreach (var propName in propertyNames)
+ {
+ schema.Properties[propName] = new OpenApiSchema { Type = "string" };
+ }
+
+ return schema;
+ }
+
+ private static OpenApiDocument CreateComprehensiveDocument(
+ List paths,
+ List schemaNames,
+ List tagNames,
+ List propertyNames)
+ {
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Comprehensive Test API", Version = "1.0.0" },
+ Paths = new OpenApiPaths(),
+ Tags = new List(),
+ Components = new OpenApiComponents
+ {
+ Schemas = new Dictionary()
+ }
+ };
+
+ // Add paths with multiple operations
+ foreach (var path in paths)
+ {
+ doc.Paths[path] = new OpenApiPathItem
+ {
+ Operations = new Dictionary
+ {
+ [OperationType.Get] = new OpenApiOperation
+ {
+ OperationId = "get" + path.Replace("/", "_").Replace("{", "").Replace("}", ""),
+ Summary = $"Get {path}",
+ Tags = new List { new OpenApiTag { Name = tagNames.First() } },
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse { Description = "Success" },
+ ["404"] = new OpenApiResponse { Description = "Not found" }
+ }
+ },
+ [OperationType.Post] = new OpenApiOperation
+ {
+ OperationId = "post" + path.Replace("/", "_").Replace("{", "").Replace("}", ""),
+ Summary = $"Create {path}",
+ Tags = new List { new OpenApiTag { Name = tagNames.Last() } },
+ Responses = new OpenApiResponses
+ {
+ ["201"] = new OpenApiResponse { Description = "Created" },
+ ["400"] = new OpenApiResponse { Description = "Bad request" }
+ }
+ }
+ }
+ };
+ }
+
+ // Add schemas with properties
+ foreach (var schemaName in schemaNames)
+ {
+ var schema = new OpenApiSchema
+ {
+ Type = "object",
+ Properties = new Dictionary()
+ };
+
+ foreach (var propName in propertyNames)
+ {
+ schema.Properties[propName] = new OpenApiSchema { Type = "string" };
+ }
+
+ doc.Components.Schemas[schemaName] = schema;
+ }
+
+ // Add tags
+ foreach (var tagName in tagNames)
+ {
+ doc.Tags.Add(new OpenApiTag { Name = tagName, Description = $"Description for {tagName}" });
+ }
+
+ return doc;
+ }
+ }
+
+
+ ///
+ /// Feature: deterministic-output, Property 1: Path Ordering
+ /// For any OpenAPI document with multiple paths, after sorting, the paths SHALL appear
+ /// in alphabetical order by path string using ordinal comparison.
+ /// **Validates: Requirements 1.1, 1.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Paths_ShouldBeSorted_Alphabetically()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithPathsGen().ToArbitrary(),
+ doc =>
+ {
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var pathKeys = sortedDoc.Paths.Keys.ToList();
+
+ // Verify paths are in alphabetical order
+ var isSorted = pathKeys.SequenceEqual(pathKeys.OrderBy(p => p, StringComparer.Ordinal));
+
+ return isSorted.Label("Paths should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// Feature: deterministic-output, Property 2: Schema Ordering
+ /// For any OpenAPI document with multiple schemas, after sorting, the schemas SHALL appear
+ /// in alphabetical order by schema name using ordinal comparison.
+ /// **Validates: Requirements 2.1, 2.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Schemas_ShouldBeSorted_Alphabetically()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithSchemasGen().ToArbitrary(),
+ doc =>
+ {
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var schemaKeys = sortedDoc.Components?.Schemas?.Keys.ToList() ?? new List();
+
+ // Verify schemas are in alphabetical order
+ var isSorted = schemaKeys.SequenceEqual(schemaKeys.OrderBy(s => s, StringComparer.Ordinal));
+
+ return isSorted.Label("Schemas should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// Feature: deterministic-output, Property 3: Property Ordering Within Schemas
+ /// For any OpenAPI schema with multiple properties, after sorting, the properties SHALL appear
+ /// in alphabetical order by property name using ordinal comparison.
+ /// **Validates: Requirements 3.1, 3.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property SchemaProperties_ShouldBeSorted_Alphabetically()
+ {
+ return Prop.ForAll(
+ SorterGenerators.SchemaWithPropertiesGen().ToArbitrary(),
+ schema =>
+ {
+ // Create a document with the schema to test through the public Sort method
+ var doc = new OpenApiDocument
+ {
+ Info = new OpenApiInfo { Title = "Test", Version = "1.0.0" },
+ Paths = new OpenApiPaths(),
+ Components = new OpenApiComponents
+ {
+ Schemas = new Dictionary
+ {
+ ["TestSchema"] = schema
+ }
+ }
+ };
+
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var sortedSchema = sortedDoc.Components!.Schemas!["TestSchema"];
+ var propKeys = sortedSchema.Properties?.Keys.ToList() ?? new List();
+
+ // Verify properties are in alphabetical order
+ var isSorted = propKeys.SequenceEqual(propKeys.OrderBy(p => p, StringComparer.Ordinal));
+
+ return isSorted.Label("Schema properties should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// Feature: deterministic-output, Property 4: Tag Ordering
+ /// For any OpenAPI document with multiple tags, after sorting, the tags SHALL appear
+ /// in alphabetical order by tag name using ordinal comparison.
+ /// **Validates: Requirements 4.1, 4.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Tags_ShouldBeSorted_Alphabetically()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithTagsGen().ToArbitrary(),
+ doc =>
+ {
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var tagNames = sortedDoc.Tags?.Select(t => t.Name).ToList() ?? new List();
+
+ // Verify tags are in alphabetical order
+ var isSorted = tagNames.SequenceEqual(tagNames.OrderBy(t => t, StringComparer.Ordinal));
+
+ return isSorted.Label("Tags should be in alphabetical order");
+ });
+ }
+
+
+ ///
+ /// Feature: deterministic-output, Property 5: Tag Group Ordering
+ /// For any OpenAPI document with tag groups, after sorting, the tag groups SHALL appear
+ /// in alphabetical order by group name, and tags within each group SHALL appear in alphabetical order.
+ /// **Validates: Requirements 5.1, 5.2, 5.3**
+ ///
+ [Property(MaxTest = 100)]
+ public Property TagGroups_ShouldBeSorted_Alphabetically()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithTagGroupsGen().ToArbitrary(),
+ doc =>
+ {
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+
+ // Read tag groups from the sorted document
+ var tagGroups = OpenApiMerger.ReadTagGroupsExtension(sortedDoc);
+ var groupNames = tagGroups.Select(g => g.Name).ToList();
+
+ // Verify tag groups are in alphabetical order
+ var groupsSorted = groupNames.SequenceEqual(groupNames.OrderBy(g => g, StringComparer.Ordinal));
+
+ // Verify tags within each group are in alphabetical order
+ var tagsWithinGroupsSorted = tagGroups.All(g =>
+ g.Tags.SequenceEqual(g.Tags.OrderBy(t => t, StringComparer.Ordinal)));
+
+ return groupsSorted.Label("Tag groups should be in alphabetical order")
+ .And(tagsWithinGroupsSorted).Label("Tags within groups should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// Feature: deterministic-output, Property 6: Security Scheme Ordering
+ /// For any OpenAPI document with multiple security schemes, after sorting, the security schemes
+ /// SHALL appear in alphabetical order by scheme name using ordinal comparison.
+ /// **Validates: Requirements 7.1, 7.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property SecuritySchemes_ShouldBeSorted_Alphabetically()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithSecuritySchemesGen().ToArbitrary(),
+ doc =>
+ {
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var schemeKeys = sortedDoc.Components?.SecuritySchemes?.Keys.ToList() ?? new List();
+
+ // Verify security schemes are in alphabetical order
+ var isSorted = schemeKeys.SequenceEqual(schemeKeys.OrderBy(s => s, StringComparer.Ordinal));
+
+ return isSorted.Label("Security schemes should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// Feature: deterministic-output, Property 7: Operation Ordering
+ /// For any path with multiple operations, after sorting, the operations SHALL appear
+ /// in HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE.
+ /// **Validates: Requirements 8.1, 8.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Operations_ShouldBeSorted_ByHttpMethodOrder()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithOperationsGen().ToArbitrary(),
+ doc =>
+ {
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var operations = sortedDoc.Paths["/resource"].Operations.Keys.ToList();
+
+ // Expected order
+ var expectedOrder = new[]
+ {
+ OperationType.Get, OperationType.Put, OperationType.Post,
+ OperationType.Delete, OperationType.Options, OperationType.Head,
+ OperationType.Patch, OperationType.Trace
+ };
+
+ // Filter expected order to only include operations that exist
+ var expectedFiltered = expectedOrder.Where(op => operations.Contains(op)).ToList();
+
+ // Verify operations are in the expected order
+ var isSorted = operations.SequenceEqual(expectedFiltered);
+
+ return isSorted.Label("Operations should be in HTTP method order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE)");
+ });
+ }
+
+
+ ///
+ /// Feature: deterministic-output, Property 8: Response Ordering
+ /// For any operation with multiple responses, after sorting, the responses SHALL appear
+ /// in ascending order by status code (treating status codes as integers, with "default" sorted last).
+ /// **Validates: Requirements 9.1, 9.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Responses_ShouldBeSorted_ByStatusCode()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithResponsesGen().ToArbitrary(),
+ doc =>
+ {
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var responses = sortedDoc.Paths["/resource"].Operations[OperationType.Get].Responses.Keys.ToList();
+
+ // Verify responses are sorted by status code (numeric ascending, "default" last)
+ var expectedOrder = responses.OrderBy(key =>
+ {
+ if (key.Equals("default", StringComparison.OrdinalIgnoreCase))
+ return int.MaxValue;
+ if (int.TryParse(key, out var code))
+ return code;
+ return int.MaxValue - 1;
+ }).ThenBy(key => key, StringComparer.Ordinal).ToList();
+
+ var isSorted = responses.SequenceEqual(expectedOrder);
+
+ return isSorted.Label("Responses should be sorted by status code (ascending, 'default' last)");
+ });
+ }
+
+ ///
+ /// Feature: deterministic-output, Property 9: Example Ordering
+ /// For any media type with multiple examples, after sorting, the examples SHALL appear
+ /// in alphabetical order by example name using ordinal comparison.
+ /// **Validates: Requirements 11.1, 11.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Examples_ShouldBeSorted_Alphabetically()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithExamplesGen().ToArbitrary(),
+ doc =>
+ {
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var examples = sortedDoc.Paths["/resource"]
+ .Operations[OperationType.Get]
+ .Responses["200"]
+ .Content["application/json"]
+ .Examples.Keys.ToList();
+
+ // Verify examples are in alphabetical order
+ var isSorted = examples.SequenceEqual(examples.OrderBy(e => e, StringComparer.Ordinal));
+
+ return isSorted.Label("Examples should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// Feature: deterministic-output, Property 10: Output Idempotence (Round-Trip)
+ /// For any valid OpenAPI document, serializing the document, then deserializing and
+ /// re-serializing SHALL produce identical JSON output.
+ /// **Validates: Requirements 1.3, 2.3, 3.3, 4.3**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Output_ShouldBeIdempotent_AfterRoundTrip()
+ {
+ return Prop.ForAll(
+ SorterGenerators.ComprehensiveDocumentGen().ToArbitrary(),
+ doc =>
+ {
+ // First sort and serialize
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+ var firstJson = sortedDoc.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+
+ // Deserialize and re-serialize
+ var reader = new OpenApiStringReader();
+ var parsedDoc = reader.Read(firstJson, out var diagnostic);
+
+ // Sort again and serialize
+ var reSortedDoc = OpenApiDocumentSorter.Sort(parsedDoc);
+ var secondJson = reSortedDoc.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+
+ // The two JSON outputs should be identical
+ var isIdempotent = firstJson == secondJson;
+
+ return isIdempotent.Label("Output should be identical after round-trip (serialize -> deserialize -> serialize)");
+ });
+ }
+
+ ///
+ /// Feature: deterministic-output, Property 11: Server Order Preservation
+ /// For any OpenAPI document with servers, the servers SHALL appear in the same order
+ /// as they were declared in source code or configuration.
+ /// **Validates: Requirements 6.1, 6.2**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Servers_ShouldPreserveDeclarationOrder()
+ {
+ return Prop.ForAll(
+ SorterGenerators.DocumentWithServersGen().ToArbitrary(),
+ doc =>
+ {
+ // Capture the original server order before sorting
+ var originalServerUrls = doc.Servers?.Select(s => s.Url).ToList() ?? new List();
+
+ // Sort the document
+ var sortedDoc = OpenApiDocumentSorter.Sort(doc);
+
+ // Get the server order after sorting
+ var sortedServerUrls = sortedDoc.Servers?.Select(s => s.Url).ToList() ?? new List();
+
+ // Verify servers maintain their original declaration order (not alphabetically sorted)
+ var orderPreserved = originalServerUrls.SequenceEqual(sortedServerUrls);
+
+ return orderPreserved.Label("Servers should preserve their original declaration order after sorting");
+ });
+ }
+}
diff --git a/Oproto.Lambda.OpenApi.Merge.Tests/SkipUnchangedTests.cs b/Oproto.Lambda.OpenApi.Merge.Tests/SkipUnchangedTests.cs
new file mode 100644
index 0000000..60bba51
--- /dev/null
+++ b/Oproto.Lambda.OpenApi.Merge.Tests/SkipUnchangedTests.cs
@@ -0,0 +1,264 @@
+namespace Oproto.Lambda.OpenApi.Merge.Tests;
+
+using Microsoft.OpenApi;
+using Microsoft.OpenApi.Extensions;
+using Microsoft.OpenApi.Models;
+using Xunit;
+
+///
+/// Unit tests for the skip-unchanged feature in the merge tool.
+/// Validates: Requirements 10.1, 10.2, 10.3, 10.4
+///
+public class SkipUnchangedTests : IDisposable
+{
+ private readonly string _testOutputDir;
+ private readonly List _createdFiles = new();
+
+ public SkipUnchangedTests()
+ {
+ _testOutputDir = Path.Combine(Path.GetTempPath(), $"skip-unchanged-tests-{Guid.NewGuid()}");
+ Directory.CreateDirectory(_testOutputDir);
+ }
+
+ public void Dispose()
+ {
+ // Cleanup test files
+ foreach (var file in _createdFiles)
+ {
+ if (File.Exists(file))
+ {
+ File.Delete(file);
+ }
+ }
+
+ if (Directory.Exists(_testOutputDir))
+ {
+ Directory.Delete(_testOutputDir, recursive: true);
+ }
+ }
+
+ ///
+ /// Tests that file is skipped when content matches and force is false.
+ /// Validates: Requirements 10.1
+ ///
+ [Fact]
+ public async Task WriteOpenApiDocument_ContentMatches_SkipsWrite()
+ {
+ // Arrange
+ var document = CreateSimpleOpenApiDocument("Test API", "1.0.0");
+ var outputPath = Path.Combine(_testOutputDir, "skip-test.json");
+ _createdFiles.Add(outputPath);
+
+ // Write initial file
+ var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+ await File.WriteAllTextAsync(outputPath, json);
+ var originalWriteTime = File.GetLastWriteTimeUtc(outputPath);
+
+ // Wait a bit to ensure timestamp would change if file is rewritten
+ await Task.Delay(50);
+
+ // Act - Try to write the same content without force
+ var wasWritten = await WriteOpenApiDocumentAsync(document, outputPath, verbose: false, force: false);
+
+ // Assert
+ Assert.False(wasWritten, "File should not have been written when content matches");
+ var newWriteTime = File.GetLastWriteTimeUtc(outputPath);
+ Assert.Equal(originalWriteTime, newWriteTime);
+ }
+
+ ///
+ /// Tests that file is written when content differs.
+ /// Validates: Requirements 10.1
+ ///
+ [Fact]
+ public async Task WriteOpenApiDocument_ContentDiffers_WritesFile()
+ {
+ // Arrange
+ var originalDocument = CreateSimpleOpenApiDocument("Original API", "1.0.0");
+ var updatedDocument = CreateSimpleOpenApiDocument("Updated API", "2.0.0");
+ var outputPath = Path.Combine(_testOutputDir, "diff-test.json");
+ _createdFiles.Add(outputPath);
+
+ // Write initial file
+ var originalJson = originalDocument.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+ await File.WriteAllTextAsync(outputPath, originalJson);
+
+ // Act - Write different content without force
+ var wasWritten = await WriteOpenApiDocumentAsync(updatedDocument, outputPath, verbose: false, force: false);
+
+ // Assert
+ Assert.True(wasWritten, "File should have been written when content differs");
+ var newContent = await File.ReadAllTextAsync(outputPath);
+ Assert.Contains("Updated API", newContent);
+ }
+
+ ///
+ /// Tests that file is written when --force is used even if content matches.
+ /// Validates: Requirements 10.3
+ ///
+ [Fact]
+ public async Task WriteOpenApiDocument_ForceFlag_WritesEvenWhenContentMatches()
+ {
+ // Arrange
+ var document = CreateSimpleOpenApiDocument("Force Test API", "1.0.0");
+ var outputPath = Path.Combine(_testOutputDir, "force-test.json");
+ _createdFiles.Add(outputPath);
+
+ // Write initial file
+ var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+ await File.WriteAllTextAsync(outputPath, json);
+ var originalWriteTime = File.GetLastWriteTimeUtc(outputPath);
+
+ // Wait a bit to ensure timestamp would change if file is rewritten
+ await Task.Delay(50);
+
+ // Act - Write with force flag
+ var wasWritten = await WriteOpenApiDocumentAsync(document, outputPath, verbose: false, force: true);
+
+ // Assert
+ Assert.True(wasWritten, "File should have been written when force is true");
+ var newWriteTime = File.GetLastWriteTimeUtc(outputPath);
+ Assert.True(newWriteTime > originalWriteTime, "File timestamp should have changed");
+ }
+
+ ///
+ /// Tests that file is written when it doesn't exist.
+ /// Validates: Requirements 10.4
+ ///
+ [Fact]
+ public async Task WriteOpenApiDocument_FileDoesNotExist_WritesFile()
+ {
+ // Arrange
+ var document = CreateSimpleOpenApiDocument("New File API", "1.0.0");
+ var outputPath = Path.Combine(_testOutputDir, "new-file-test.json");
+ _createdFiles.Add(outputPath);
+
+ // Ensure file doesn't exist
+ Assert.False(File.Exists(outputPath));
+
+ // Act
+ var wasWritten = await WriteOpenApiDocumentAsync(document, outputPath, verbose: false, force: false);
+
+ // Assert
+ Assert.True(wasWritten, "File should have been written when it doesn't exist");
+ Assert.True(File.Exists(outputPath), "File should exist after write");
+ }
+
+ ///
+ /// Tests that log message is output when skipping in verbose mode.
+ /// Validates: Requirements 10.2
+ ///
+ [Fact]
+ public async Task WriteOpenApiDocument_SkipWithVerbose_LogsMessage()
+ {
+ // Arrange
+ var document = CreateSimpleOpenApiDocument("Verbose Test API", "1.0.0");
+ var outputPath = Path.Combine(_testOutputDir, "verbose-test.json");
+ _createdFiles.Add(outputPath);
+
+ // Write initial file
+ var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+ await File.WriteAllTextAsync(outputPath, json);
+
+ // Capture console output
+ var originalOut = Console.Out;
+ using var stringWriter = new StringWriter();
+ Console.SetOut(stringWriter);
+
+ try
+ {
+ // Act
+ await WriteOpenApiDocumentAsync(document, outputPath, verbose: true, force: false);
+
+ // Assert
+ var output = stringWriter.ToString();
+ Assert.Contains("unchanged", output.ToLowerInvariant());
+ Assert.Contains("skipping", output.ToLowerInvariant());
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ }
+ }
+
+ #region Helper Methods
+
+ private static OpenApiDocument CreateSimpleOpenApiDocument(string title, string version)
+ {
+ return new OpenApiDocument
+ {
+ Info = new OpenApiInfo
+ {
+ Title = title,
+ Version = version
+ },
+ Paths = new OpenApiPaths
+ {
+ ["/test"] = new OpenApiPathItem
+ {
+ Operations = new Dictionary
+ {
+ [OperationType.Get] = new OpenApiOperation
+ {
+ Summary = "Test operation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse { Description = "Success" }
+ }
+ }
+ }
+ }
+ }
+ };
+ }
+
+ ///
+ /// Simulates the WriteOpenApiDocumentAsync logic from MergeCommand for testing.
+ /// This mirrors the implementation in MergeCommand.cs.
+ ///
+ private static async Task WriteOpenApiDocumentAsync(
+ OpenApiDocument document,
+ string outputPath,
+ bool verbose,
+ bool force)
+ {
+ // Ensure output directory exists
+ var outputDir = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
+ {
+ Directory.CreateDirectory(outputDir);
+ }
+
+ var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+
+ // Check if file exists and content matches (skip-unchanged feature)
+ if (!force && File.Exists(outputPath))
+ {
+ try
+ {
+ var existingContent = await File.ReadAllTextAsync(outputPath);
+ if (existingContent == json)
+ {
+ if (verbose)
+ {
+ Console.WriteLine($"Output unchanged, skipping write: {outputPath}");
+ }
+ return false; // Indicates file was not written
+ }
+ }
+ catch (IOException ex)
+ {
+ // Log warning and proceed with write if we can't read existing file
+ if (verbose)
+ {
+ Console.Error.WriteLine($"Warning: Could not read existing file for comparison: {ex.Message}");
+ }
+ }
+ }
+
+ await File.WriteAllTextAsync(outputPath, json);
+ return true; // Indicates file was written
+ }
+
+ #endregion
+}
diff --git a/Oproto.Lambda.OpenApi.Merge.Tool/Commands/MergeCommand.cs b/Oproto.Lambda.OpenApi.Merge.Tool/Commands/MergeCommand.cs
index 41d9b2f..0624a44 100644
--- a/Oproto.Lambda.OpenApi.Merge.Tool/Commands/MergeCommand.cs
+++ b/Oproto.Lambda.OpenApi.Merge.Tool/Commands/MergeCommand.cs
@@ -43,6 +43,10 @@ public MergeCommand() : base("merge", "Merge multiple OpenAPI specifications")
new[] { "-v", "--verbose" },
"Show detailed progress and warnings");
+ var forceOption = new Option(
+ new[] { "-f", "--force" },
+ "Force write output even if unchanged");
+
// Positional argument for direct file list
var filesArgument = new Argument(
"files",
@@ -57,10 +61,11 @@ public MergeCommand() : base("merge", "Merge multiple OpenAPI specifications")
AddOption(versionOption);
AddOption(schemaConflictOption);
AddOption(verboseOption);
+ AddOption(forceOption);
AddArgument(filesArgument);
this.SetHandler(ExecuteAsync, configOption, outputOption, titleOption,
- versionOption, schemaConflictOption, verboseOption, filesArgument);
+ versionOption, schemaConflictOption, verboseOption, forceOption, filesArgument);
}
private async Task ExecuteAsync(
@@ -70,6 +75,7 @@ private async Task ExecuteAsync(
string? version,
SchemaConflictStrategy schemaConflict,
bool verbose,
+ bool force,
FileInfo[] files)
{
try
@@ -147,7 +153,7 @@ private async Task ExecuteAsync(
Console.WriteLine($"Writing merged specification to: {outputPath}");
}
- await WriteOpenApiDocumentAsync(result.Document, outputPath, verbose);
+ var wasWritten = await WriteOpenApiDocumentAsync(result.Document, outputPath, verbose, force);
if (verbose)
{
@@ -155,7 +161,14 @@ private async Task ExecuteAsync(
}
else
{
- Console.WriteLine($"Merged {documents.Count} specifications into {outputPath}");
+ if (wasWritten)
+ {
+ Console.WriteLine($"Merged {documents.Count} specifications into {outputPath}");
+ }
+ else
+ {
+ Console.WriteLine($"Output unchanged, skipped writing: {outputPath}");
+ }
}
return 0;
@@ -334,7 +347,7 @@ private static async Task LoadOpenApiDocumentAsync(string path,
return result.OpenApiDocument;
}
- private static async Task WriteOpenApiDocumentAsync(OpenApiDocument document, string outputPath, bool verbose)
+ private static async Task WriteOpenApiDocumentAsync(OpenApiDocument document, string outputPath, bool verbose, bool force)
{
// Validate the merged document before writing
var errors = document.Validate(Microsoft.OpenApi.Validations.ValidationRuleSet.GetDefaultRuleSet());
@@ -361,6 +374,33 @@ private static async Task WriteOpenApiDocumentAsync(OpenApiDocument document, st
}
var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
+
+ // Check if file exists and content matches (skip-unchanged feature)
+ if (!force && File.Exists(outputPath))
+ {
+ try
+ {
+ var existingContent = await File.ReadAllTextAsync(outputPath);
+ if (existingContent == json)
+ {
+ if (verbose)
+ {
+ Console.WriteLine($"Output unchanged, skipping write: {outputPath}");
+ }
+ return false; // Indicates file was not written
+ }
+ }
+ catch (IOException ex)
+ {
+ // Log warning and proceed with write if we can't read existing file
+ if (verbose)
+ {
+ Console.Error.WriteLine($"Warning: Could not read existing file for comparison: {ex.Message}");
+ }
+ }
+ }
+
await File.WriteAllTextAsync(outputPath, json);
+ return true; // Indicates file was written
}
}
diff --git a/Oproto.Lambda.OpenApi.Merge/OpenApiDocumentSorter.cs b/Oproto.Lambda.OpenApi.Merge/OpenApiDocumentSorter.cs
new file mode 100644
index 0000000..2e0fec3
--- /dev/null
+++ b/Oproto.Lambda.OpenApi.Merge/OpenApiDocumentSorter.cs
@@ -0,0 +1,389 @@
+namespace Oproto.Lambda.OpenApi.Merge;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Models;
+
+///
+/// Sorts OpenAPI document collections for deterministic output.
+///
+public static class OpenApiDocumentSorter
+{
+ ///
+ /// HTTP method ordering for operations within a path.
+ ///
+ private static readonly OperationType[] OperationOrder =
+ {
+ OperationType.Get,
+ OperationType.Put,
+ OperationType.Post,
+ OperationType.Delete,
+ OperationType.Options,
+ OperationType.Head,
+ OperationType.Patch,
+ OperationType.Trace
+ };
+
+ ///
+ /// Sorts all collections in the document for deterministic output.
+ /// Modifies the document in place and returns it.
+ ///
+ /// The OpenAPI document to sort.
+ /// The sorted document (same instance, modified in place).
+ public static OpenApiDocument Sort(OpenApiDocument document)
+ {
+ if (document == null)
+ throw new ArgumentNullException(nameof(document));
+
+ // Sort paths
+ if (document.Paths != null && document.Paths.Count > 0)
+ {
+ document.Paths = SortPaths(document.Paths);
+ }
+
+ // Sort schemas
+ if (document.Components?.Schemas != null && document.Components.Schemas.Count > 0)
+ {
+ document.Components.Schemas = SortSchemas(document.Components.Schemas);
+ }
+
+ // Sort tags
+ if (document.Tags != null && document.Tags.Count > 0)
+ {
+ document.Tags = SortTags(document.Tags);
+ }
+
+ // Sort security schemes
+ if (document.Components?.SecuritySchemes != null && document.Components.SecuritySchemes.Count > 0)
+ {
+ document.Components.SecuritySchemes = SortSecuritySchemes(document.Components.SecuritySchemes);
+ }
+
+ // Sort tag groups
+ SortTagGroups(document);
+
+ return document;
+ }
+
+
+ ///
+ /// Sorts paths alphabetically by path string.
+ ///
+ /// The paths to sort.
+ /// A new OpenApiPaths with sorted entries.
+ internal static OpenApiPaths SortPaths(OpenApiPaths paths)
+ {
+ if (paths == null || paths.Count == 0)
+ return paths ?? new OpenApiPaths();
+
+ var sortedPaths = new OpenApiPaths();
+ foreach (var path in paths.OrderBy(p => p.Key, StringComparer.Ordinal))
+ {
+ sortedPaths[path.Key] = SortPathItem(path.Value);
+ }
+ return sortedPaths;
+ }
+
+ ///
+ /// Sorts operations within a path item and their responses/examples.
+ ///
+ private static OpenApiPathItem SortPathItem(OpenApiPathItem pathItem)
+ {
+ if (pathItem?.Operations == null || pathItem.Operations.Count == 0)
+ return pathItem ?? new OpenApiPathItem();
+
+ pathItem.Operations = SortOperations(pathItem.Operations);
+ return pathItem;
+ }
+
+ ///
+ /// Sorts schemas alphabetically by name and sorts properties within each schema.
+ ///
+ /// The schemas to sort.
+ /// A new dictionary with sorted entries.
+ internal static IDictionary SortSchemas(IDictionary schemas)
+ {
+ if (schemas == null || schemas.Count == 0)
+ return schemas ?? new Dictionary();
+
+ var sortedSchemas = new Dictionary();
+ foreach (var schema in schemas.OrderBy(s => s.Key, StringComparer.Ordinal))
+ {
+ sortedSchemas[schema.Key] = SortSchemaProperties(schema.Value);
+ }
+ return sortedSchemas;
+ }
+
+ ///
+ /// Sorts properties within a schema alphabetically.
+ ///
+ /// The schema to sort.
+ /// The schema with sorted properties.
+ internal static OpenApiSchema SortSchemaProperties(OpenApiSchema schema)
+ {
+ if (schema == null)
+ return new OpenApiSchema();
+
+ if (schema.Properties != null && schema.Properties.Count > 0)
+ {
+ var sortedProperties = new Dictionary();
+ foreach (var prop in schema.Properties.OrderBy(p => p.Key, StringComparer.Ordinal))
+ {
+ // Recursively sort nested schema properties
+ sortedProperties[prop.Key] = SortSchemaProperties(prop.Value);
+ }
+ schema.Properties = sortedProperties;
+ }
+
+ // Sort items schema if present (for arrays)
+ if (schema.Items != null)
+ {
+ schema.Items = SortSchemaProperties(schema.Items);
+ }
+
+ // Sort additionalProperties schema if present
+ if (schema.AdditionalProperties != null)
+ {
+ schema.AdditionalProperties = SortSchemaProperties(schema.AdditionalProperties);
+ }
+
+ // Sort allOf, oneOf, anyOf schemas
+ if (schema.AllOf != null && schema.AllOf.Count > 0)
+ {
+ schema.AllOf = schema.AllOf.Select(SortSchemaProperties).ToList();
+ }
+ if (schema.OneOf != null && schema.OneOf.Count > 0)
+ {
+ schema.OneOf = schema.OneOf.Select(SortSchemaProperties).ToList();
+ }
+ if (schema.AnyOf != null && schema.AnyOf.Count > 0)
+ {
+ schema.AnyOf = schema.AnyOf.Select(SortSchemaProperties).ToList();
+ }
+
+ return schema;
+ }
+
+
+ ///
+ /// Sorts tags alphabetically by name.
+ ///
+ /// The tags to sort.
+ /// A new list with sorted tags.
+ internal static IList SortTags(IList tags)
+ {
+ if (tags == null || tags.Count == 0)
+ return tags ?? new List();
+
+ return tags.OrderBy(t => t.Name, StringComparer.Ordinal).ToList();
+ }
+
+ ///
+ /// Sorts security schemes alphabetically by name.
+ ///
+ /// The security schemes to sort.
+ /// A new dictionary with sorted entries.
+ internal static IDictionary SortSecuritySchemes(
+ IDictionary schemes)
+ {
+ if (schemes == null || schemes.Count == 0)
+ return schemes ?? new Dictionary();
+
+ var sortedSchemes = new Dictionary();
+ foreach (var scheme in schemes.OrderBy(s => s.Key, StringComparer.Ordinal))
+ {
+ sortedSchemes[scheme.Key] = scheme.Value;
+ }
+ return sortedSchemes;
+ }
+
+ ///
+ /// Sorts operations within a path by HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE.
+ ///
+ /// The operations to sort.
+ /// A new dictionary with sorted entries.
+ internal static IDictionary SortOperations(
+ IDictionary operations)
+ {
+ if (operations == null || operations.Count == 0)
+ return operations ?? new Dictionary();
+
+ var sortedOperations = new Dictionary();
+ foreach (var opType in OperationOrder)
+ {
+ if (operations.TryGetValue(opType, out var operation))
+ {
+ // Sort responses and examples within the operation
+ sortedOperations[opType] = SortOperation(operation);
+ }
+ }
+ return sortedOperations;
+ }
+
+ ///
+ /// Sorts responses and examples within an operation.
+ ///
+ private static OpenApiOperation SortOperation(OpenApiOperation operation)
+ {
+ if (operation == null)
+ return new OpenApiOperation();
+
+ // Sort responses
+ if (operation.Responses != null && operation.Responses.Count > 0)
+ {
+ operation.Responses = SortResponses(operation.Responses);
+ }
+
+ // Sort request body examples if present
+ if (operation.RequestBody?.Content != null)
+ {
+ foreach (var content in operation.RequestBody.Content.Values)
+ {
+ if (content.Examples != null && content.Examples.Count > 0)
+ {
+ content.Examples = SortExamples(content.Examples);
+ }
+ }
+ }
+
+ return operation;
+ }
+
+
+ ///
+ /// Sorts responses by status code (ascending), with "default" sorted last.
+ ///
+ /// The responses to sort.
+ /// A new OpenApiResponses with sorted entries.
+ internal static OpenApiResponses SortResponses(OpenApiResponses responses)
+ {
+ if (responses == null || responses.Count == 0)
+ return responses ?? new OpenApiResponses();
+
+ var sortedResponses = new OpenApiResponses();
+
+ // Sort by status code: numeric codes first (ascending), then "default" last
+ var sortedKeys = responses.Keys.OrderBy(key =>
+ {
+ if (key.Equals("default", StringComparison.OrdinalIgnoreCase))
+ return int.MaxValue;
+ if (int.TryParse(key, out var code))
+ return code;
+ return int.MaxValue - 1; // Unknown non-numeric codes before "default"
+ }).ThenBy(key => key, StringComparer.Ordinal);
+
+ foreach (var key in sortedKeys)
+ {
+ var response = responses[key];
+ // Sort examples within response content
+ if (response.Content != null)
+ {
+ foreach (var content in response.Content.Values)
+ {
+ if (content.Examples != null && content.Examples.Count > 0)
+ {
+ content.Examples = SortExamples(content.Examples);
+ }
+ }
+ }
+ sortedResponses[key] = response;
+ }
+ return sortedResponses;
+ }
+
+ ///
+ /// Sorts examples alphabetically by name.
+ ///
+ /// The examples to sort.
+ /// A new dictionary with sorted entries.
+ internal static IDictionary SortExamples(IDictionary examples)
+ {
+ if (examples == null || examples.Count == 0)
+ return examples ?? new Dictionary();
+
+ var sortedExamples = new Dictionary();
+ foreach (var example in examples.OrderBy(e => e.Key, StringComparer.Ordinal))
+ {
+ sortedExamples[example.Key] = example.Value;
+ }
+ return sortedExamples;
+ }
+
+ ///
+ /// Sorts tag groups alphabetically by name, and tags within groups alphabetically.
+ ///
+ /// The document containing tag groups to sort.
+ internal static void SortTagGroups(OpenApiDocument document)
+ {
+ if (document?.Extensions == null)
+ return;
+
+ if (!document.Extensions.TryGetValue("x-tagGroups", out var extension))
+ return;
+
+ if (extension is not OpenApiArray groupsArray || groupsArray.Count == 0)
+ return;
+
+ // Parse tag groups
+ var tagGroups = new List<(string Name, List Tags)>();
+ foreach (var item in groupsArray)
+ {
+ if (item is not OpenApiObject groupObj)
+ continue;
+
+ string? name = null;
+ if (groupObj.TryGetValue("name", out var nameValue) && nameValue is OpenApiString nameString)
+ {
+ name = nameString.Value;
+ }
+
+ if (string.IsNullOrEmpty(name))
+ continue;
+
+ var tags = new List();
+ if (groupObj.TryGetValue("tags", out var tagsValue) && tagsValue is OpenApiArray tagsArray)
+ {
+ foreach (var tagItem in tagsArray.OfType())
+ {
+ if (!string.IsNullOrEmpty(tagItem.Value))
+ {
+ tags.Add(tagItem.Value);
+ }
+ }
+ }
+
+ tagGroups.Add((name!, tags));
+ }
+
+ if (tagGroups.Count == 0)
+ return;
+
+ // Sort tag groups by name, and tags within each group
+ var sortedGroups = tagGroups
+ .OrderBy(g => g.Name, StringComparer.Ordinal)
+ .Select(g => (g.Name, Tags: g.Tags.OrderBy(t => t, StringComparer.Ordinal).ToList()))
+ .ToList();
+
+ // Rebuild the extension
+ var sortedArray = new OpenApiArray();
+ foreach (var group in sortedGroups)
+ {
+ var tagsArray = new OpenApiArray();
+ foreach (var tag in group.Tags)
+ {
+ tagsArray.Add(new OpenApiString(tag));
+ }
+
+ var groupObject = new OpenApiObject
+ {
+ ["name"] = new OpenApiString(group.Name),
+ ["tags"] = tagsArray
+ };
+ sortedArray.Add(groupObject);
+ }
+
+ document.Extensions["x-tagGroups"] = sortedArray;
+ }
+}
diff --git a/Oproto.Lambda.OpenApi.Merge/OpenApiMerger.cs b/Oproto.Lambda.OpenApi.Merge/OpenApiMerger.cs
index e3dd760..8e9815e 100644
--- a/Oproto.Lambda.OpenApi.Merge/OpenApiMerger.cs
+++ b/Oproto.Lambda.OpenApi.Merge/OpenApiMerger.cs
@@ -112,6 +112,9 @@ public MergeResult Merge(
// Phase 5: Merge tag groups from all sources
MergeTagGroups(mergedDocument, documentList);
+ // Phase 6: Sort for deterministic output
+ OpenApiDocumentSorter.Sort(mergedDocument);
+
return new MergeResult(mergedDocument, warnings, success: true);
}
diff --git a/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs b/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs
index 2f1517b..5172a10 100644
--- a/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs
+++ b/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs
@@ -181,7 +181,8 @@ private OpenApiDocument MergeOpenApiDocs(ImmutableArray docs,
// Apply tag groups extension
ApplyTagGroupsExtension(emptyDoc, tagGroups);
- return emptyDoc;
+ // Sort for deterministic output
+ return SortDocument(emptyDoc);
}
if (validDocs.Count == 1)
@@ -204,7 +205,8 @@ private OpenApiDocument MergeOpenApiDocs(ImmutableArray docs,
// Apply tag groups extension
ApplyTagGroupsExtension(doc, tagGroups);
- return doc;
+ // Sort for deterministic output
+ return SortDocument(doc);
}
var mergedDoc = new OpenApiDocument
@@ -245,7 +247,8 @@ private OpenApiDocument MergeOpenApiDocs(ImmutableArray docs,
// Apply tag groups extension
ApplyTagGroupsExtension(mergedDoc, tagGroups);
- return mergedDoc;
+ // Sort for deterministic output
+ return SortDocument(mergedDoc);
}
///
diff --git a/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator_Sorting.cs b/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator_Sorting.cs
new file mode 100644
index 0000000..35ecd94
--- /dev/null
+++ b/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator_Sorting.cs
@@ -0,0 +1,369 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Models;
+
+namespace Oproto.Lambda.OpenApi.SourceGenerator;
+
+///
+/// Partial class containing sorting methods for deterministic OpenAPI output.
+///
+public partial class OpenApiSpecGenerator
+{
+ ///
+ /// HTTP method ordering for operations within a path.
+ ///
+ private static readonly OperationType[] OperationOrder =
+ {
+ OperationType.Get,
+ OperationType.Put,
+ OperationType.Post,
+ OperationType.Delete,
+ OperationType.Options,
+ OperationType.Head,
+ OperationType.Patch,
+ OperationType.Trace
+ };
+
+ ///
+ /// Sorts all collections in the document for deterministic output.
+ /// Modifies the document in place and returns it.
+ ///
+ /// The OpenAPI document to sort.
+ /// The sorted document (same instance, modified in place).
+ private static OpenApiDocument SortDocument(OpenApiDocument document)
+ {
+ if (document == null)
+ return new OpenApiDocument();
+
+ // Sort paths
+ if (document.Paths != null && document.Paths.Count > 0)
+ {
+ document.Paths = SortPaths(document.Paths);
+ }
+
+ // Sort schemas
+ if (document.Components?.Schemas != null && document.Components.Schemas.Count > 0)
+ {
+ document.Components.Schemas = SortSchemas(document.Components.Schemas);
+ }
+
+ // Sort tags
+ if (document.Tags != null && document.Tags.Count > 0)
+ {
+ document.Tags = SortTags(document.Tags);
+ }
+
+ // Sort security schemes
+ if (document.Components?.SecuritySchemes != null && document.Components.SecuritySchemes.Count > 0)
+ {
+ document.Components.SecuritySchemes = SortSecuritySchemes(document.Components.SecuritySchemes);
+ }
+
+ // Sort tag groups
+ SortTagGroupsExtension(document);
+
+ return document;
+ }
+
+ ///
+ /// Sorts paths alphabetically by path string.
+ ///
+ private static OpenApiPaths SortPaths(OpenApiPaths paths)
+ {
+ if (paths == null || paths.Count == 0)
+ return paths ?? new OpenApiPaths();
+
+ var sortedPaths = new OpenApiPaths();
+ foreach (var path in paths.OrderBy(p => p.Key, StringComparer.Ordinal))
+ {
+ sortedPaths[path.Key] = SortPathItem(path.Value);
+ }
+ return sortedPaths;
+ }
+
+ ///
+ /// Sorts operations within a path item and their responses/examples.
+ ///
+ private static OpenApiPathItem SortPathItem(OpenApiPathItem pathItem)
+ {
+ if (pathItem?.Operations == null || pathItem.Operations.Count == 0)
+ return pathItem ?? new OpenApiPathItem();
+
+ pathItem.Operations = SortOperations(pathItem.Operations);
+ return pathItem;
+ }
+
+ ///
+ /// Sorts schemas alphabetically by name and sorts properties within each schema.
+ ///
+ private static IDictionary SortSchemas(IDictionary schemas)
+ {
+ if (schemas == null || schemas.Count == 0)
+ return schemas ?? new Dictionary();
+
+ var sortedSchemas = new Dictionary();
+ foreach (var schema in schemas.OrderBy(s => s.Key, StringComparer.Ordinal))
+ {
+ sortedSchemas[schema.Key] = SortSchemaProperties(schema.Value);
+ }
+ return sortedSchemas;
+ }
+
+ ///
+ /// Sorts properties within a schema alphabetically.
+ ///
+ private static OpenApiSchema SortSchemaProperties(OpenApiSchema schema)
+ {
+ if (schema == null)
+ return new OpenApiSchema();
+
+ if (schema.Properties != null && schema.Properties.Count > 0)
+ {
+ var sortedProperties = new Dictionary();
+ foreach (var prop in schema.Properties.OrderBy(p => p.Key, StringComparer.Ordinal))
+ {
+ // Recursively sort nested schema properties
+ sortedProperties[prop.Key] = SortSchemaProperties(prop.Value);
+ }
+ schema.Properties = sortedProperties;
+ }
+
+ // Sort items schema if present (for arrays)
+ if (schema.Items != null)
+ {
+ schema.Items = SortSchemaProperties(schema.Items);
+ }
+
+ // Sort additionalProperties schema if present
+ if (schema.AdditionalProperties != null)
+ {
+ schema.AdditionalProperties = SortSchemaProperties(schema.AdditionalProperties);
+ }
+
+ // Sort allOf, oneOf, anyOf schemas
+ if (schema.AllOf != null && schema.AllOf.Count > 0)
+ {
+ schema.AllOf = schema.AllOf.Select(SortSchemaProperties).ToList();
+ }
+ if (schema.OneOf != null && schema.OneOf.Count > 0)
+ {
+ schema.OneOf = schema.OneOf.Select(SortSchemaProperties).ToList();
+ }
+ if (schema.AnyOf != null && schema.AnyOf.Count > 0)
+ {
+ schema.AnyOf = schema.AnyOf.Select(SortSchemaProperties).ToList();
+ }
+
+ return schema;
+ }
+
+ ///
+ /// Sorts tags alphabetically by name.
+ ///
+ private static IList SortTags(IList tags)
+ {
+ if (tags == null || tags.Count == 0)
+ return tags ?? new List();
+
+ return tags.OrderBy(t => t.Name, StringComparer.Ordinal).ToList();
+ }
+
+ ///
+ /// Sorts security schemes alphabetically by name.
+ ///
+ private static IDictionary SortSecuritySchemes(
+ IDictionary schemes)
+ {
+ if (schemes == null || schemes.Count == 0)
+ return schemes ?? new Dictionary();
+
+ var sortedSchemes = new Dictionary();
+ foreach (var scheme in schemes.OrderBy(s => s.Key, StringComparer.Ordinal))
+ {
+ sortedSchemes[scheme.Key] = scheme.Value;
+ }
+ return sortedSchemes;
+ }
+
+ ///
+ /// Sorts operations within a path by HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE.
+ ///
+ private static IDictionary SortOperations(
+ IDictionary operations)
+ {
+ if (operations == null || operations.Count == 0)
+ return operations ?? new Dictionary();
+
+ var sortedOperations = new Dictionary();
+ foreach (var opType in OperationOrder)
+ {
+ if (operations.TryGetValue(opType, out var operation))
+ {
+ // Sort responses and examples within the operation
+ sortedOperations[opType] = SortOperation(operation);
+ }
+ }
+ return sortedOperations;
+ }
+
+ ///
+ /// Sorts responses and examples within an operation.
+ ///
+ private static OpenApiOperation SortOperation(OpenApiOperation operation)
+ {
+ if (operation == null)
+ return new OpenApiOperation();
+
+ // Sort responses
+ if (operation.Responses != null && operation.Responses.Count > 0)
+ {
+ operation.Responses = SortResponses(operation.Responses);
+ }
+
+ // Sort request body examples if present
+ if (operation.RequestBody?.Content != null)
+ {
+ foreach (var content in operation.RequestBody.Content.Values)
+ {
+ if (content.Examples != null && content.Examples.Count > 0)
+ {
+ content.Examples = SortExamples(content.Examples);
+ }
+ }
+ }
+
+ return operation;
+ }
+
+ ///
+ /// Sorts responses by status code (ascending), with "default" sorted last.
+ ///
+ private static OpenApiResponses SortResponses(OpenApiResponses responses)
+ {
+ if (responses == null || responses.Count == 0)
+ return responses ?? new OpenApiResponses();
+
+ var sortedResponses = new OpenApiResponses();
+
+ // Sort by status code: numeric codes first (ascending), then "default" last
+ var sortedKeys = responses.Keys.OrderBy(key =>
+ {
+ if (key.Equals("default", StringComparison.OrdinalIgnoreCase))
+ return int.MaxValue;
+ if (int.TryParse(key, out var code))
+ return code;
+ return int.MaxValue - 1; // Unknown non-numeric codes before "default"
+ }).ThenBy(key => key, StringComparer.Ordinal);
+
+ foreach (var key in sortedKeys)
+ {
+ var response = responses[key];
+ // Sort examples within response content
+ if (response.Content != null)
+ {
+ foreach (var content in response.Content.Values)
+ {
+ if (content.Examples != null && content.Examples.Count > 0)
+ {
+ content.Examples = SortExamples(content.Examples);
+ }
+ }
+ }
+ sortedResponses[key] = response;
+ }
+ return sortedResponses;
+ }
+
+ ///
+ /// Sorts examples alphabetically by name.
+ ///
+ private static IDictionary SortExamples(IDictionary examples)
+ {
+ if (examples == null || examples.Count == 0)
+ return examples ?? new Dictionary();
+
+ var sortedExamples = new Dictionary();
+ foreach (var example in examples.OrderBy(e => e.Key, StringComparer.Ordinal))
+ {
+ sortedExamples[example.Key] = example.Value;
+ }
+ return sortedExamples;
+ }
+
+ ///
+ /// Sorts tag groups alphabetically by name, and tags within groups alphabetically.
+ ///
+ private static void SortTagGroupsExtension(OpenApiDocument document)
+ {
+ if (document?.Extensions == null)
+ return;
+
+ if (!document.Extensions.TryGetValue("x-tagGroups", out var extension))
+ return;
+
+ if (extension is not OpenApiArray groupsArray || groupsArray.Count == 0)
+ return;
+
+ // Parse tag groups
+ var tagGroups = new List<(string Name, List Tags)>();
+ foreach (var item in groupsArray)
+ {
+ if (item is not OpenApiObject groupObj)
+ continue;
+
+ string name = null;
+ if (groupObj.TryGetValue("name", out var nameValue) && nameValue is OpenApiString nameString)
+ {
+ name = nameString.Value;
+ }
+
+ if (string.IsNullOrEmpty(name))
+ continue;
+
+ var tags = new List();
+ if (groupObj.TryGetValue("tags", out var tagsValue) && tagsValue is OpenApiArray tagsArray)
+ {
+ foreach (var tagItem in tagsArray.OfType())
+ {
+ if (!string.IsNullOrEmpty(tagItem.Value))
+ {
+ tags.Add(tagItem.Value);
+ }
+ }
+ }
+
+ tagGroups.Add((name, tags));
+ }
+
+ if (tagGroups.Count == 0)
+ return;
+
+ // Sort tag groups by name, and tags within each group
+ var sortedGroups = tagGroups
+ .OrderBy(g => g.Name, StringComparer.Ordinal)
+ .Select(g => (g.Name, Tags: g.Tags.OrderBy(t => t, StringComparer.Ordinal).ToList()))
+ .ToList();
+
+ // Rebuild the extension
+ var sortedArray = new OpenApiArray();
+ foreach (var group in sortedGroups)
+ {
+ var tagsArray = new OpenApiArray();
+ foreach (var tag in group.Tags)
+ {
+ tagsArray.Add(new OpenApiString(tag));
+ }
+
+ var groupObject = new OpenApiObject
+ {
+ ["name"] = new OpenApiString(group.Name),
+ ["tags"] = tagsArray
+ };
+ sortedArray.Add(groupObject);
+ }
+
+ document.Extensions["x-tagGroups"] = sortedArray;
+ }
+}
diff --git a/Oproto.Lambda.OpenApi.Tests/DeterministicGeneratorPropertyTests.cs b/Oproto.Lambda.OpenApi.Tests/DeterministicGeneratorPropertyTests.cs
new file mode 100644
index 0000000..b81500a
--- /dev/null
+++ b/Oproto.Lambda.OpenApi.Tests/DeterministicGeneratorPropertyTests.cs
@@ -0,0 +1,454 @@
+using System.Text.Json;
+using FsCheck;
+using FsCheck.Xunit;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Oproto.Lambda.OpenApi.SourceGenerator;
+
+namespace Oproto.Lambda.OpenApi.Tests;
+
+///
+/// Property-based tests for deterministic output from the OpenAPI source generator.
+///
+public class DeterministicGeneratorPropertyTests
+{
+ ///
+ /// Generators for deterministic output test data.
+ ///
+ private static class GeneratorTestData
+ {
+ public static Gen PathSegmentGen()
+ {
+ return Gen.Elements("users", "products", "orders", "items", "api", "v1", "admin", "auth");
+ }
+
+ public static Gen TagNameGen()
+ {
+ return Gen.Elements("Users", "Products", "Orders", "Items", "Admin", "Auth");
+ }
+
+ public static Gen PropertyNameGen()
+ {
+ return Gen.Elements("id", "name", "value", "count", "status", "email", "phone", "address");
+ }
+
+ public static Gen SchemaNameGen()
+ {
+ return Gen.Elements("User", "Product", "Order", "Item", "Response", "Request");
+ }
+
+ public static Gen TagGroupNameGen()
+ {
+ return Gen.Elements("User Management", "Product Catalog", "Order Processing", "Administration");
+ }
+
+ ///
+ /// Generates source code with multiple paths in random order.
+ ///
+ public static Gen SourceWithMultiplePathsGen()
+ {
+ return from pathCount in Gen.Choose(2, 4)
+ from segments in Gen.ListOf(pathCount, PathSegmentGen())
+ let uniqueSegments = segments.Distinct().ToList()
+ where uniqueSegments.Count >= 2
+ select GenerateSourceWithPaths(uniqueSegments);
+ }
+
+ ///
+ /// Generates source code with multiple tags in random order.
+ ///
+ public static Gen SourceWithMultipleTagsGen()
+ {
+ return from tagCount in Gen.Choose(2, 4)
+ from tags in Gen.ListOf(tagCount, TagNameGen())
+ let uniqueTags = tags.Distinct().ToList()
+ where uniqueTags.Count >= 2
+ select GenerateSourceWithTags(uniqueTags);
+ }
+
+ ///
+ /// Generates source code with a schema containing multiple properties.
+ ///
+ public static Gen SourceWithSchemaPropertiesGen()
+ {
+ return from propCount in Gen.Choose(2, 5)
+ from props in Gen.ListOf(propCount, PropertyNameGen())
+ let uniqueProps = props.Distinct().ToList()
+ where uniqueProps.Count >= 2
+ select GenerateSourceWithSchemaProperties(uniqueProps);
+ }
+
+ ///
+ /// Generates source code with multiple tag groups.
+ ///
+ public static Gen SourceWithTagGroupsGen()
+ {
+ return from groupCount in Gen.Choose(2, 3)
+ from groups in Gen.ListOf(groupCount, TagGroupNameGen())
+ from tagCount in Gen.Choose(2, 3)
+ from tags in Gen.ListOf(tagCount, TagNameGen())
+ let uniqueGroups = groups.Distinct().ToList()
+ let uniqueTags = tags.Distinct().ToList()
+ where uniqueGroups.Count >= 2 && uniqueTags.Count >= 2
+ select GenerateSourceWithTagGroups(uniqueGroups, uniqueTags);
+ }
+
+ private static string GenerateSourceWithPaths(List pathSegments)
+ {
+ var methods = string.Join("\n", pathSegments.Select((segment, index) => $@"
+ [LambdaFunction]
+ [HttpApi(LambdaHttpMethod.Get, ""/{segment}"")]
+ [OpenApiTag(""Default"")]
+ public string Get{char.ToUpper(segment[0])}{segment.Substring(1)}() => ""test"";"));
+
+ return $@"
+using Amazon.Lambda.Annotations;
+using Amazon.Lambda.Annotations.APIGateway;
+using Oproto.Lambda.OpenApi.Attributes;
+
+[assembly: OpenApiInfo(""Test API"", ""1.0.0"")]
+
+public class TestFunctions
+{{
+{methods}
+}}";
+ }
+
+ private static string GenerateSourceWithTags(List tags)
+ {
+ var methods = string.Join("\n", tags.Select((tag, index) => $@"
+ [LambdaFunction]
+ [HttpApi(LambdaHttpMethod.Get, ""/endpoint{index}"")]
+ [OpenApiTag(""{tag}"")]
+ public string GetEndpoint{index}() => ""test"";"));
+
+ var tagDefinitions = string.Join("\n", tags.Select(tag =>
+ $@"[assembly: OpenApiTagDefinition(""{tag}"", Description = ""Description for {tag}"")]"));
+
+ return $@"
+using Amazon.Lambda.Annotations;
+using Amazon.Lambda.Annotations.APIGateway;
+using Oproto.Lambda.OpenApi.Attributes;
+
+[assembly: OpenApiInfo(""Test API"", ""1.0.0"")]
+{tagDefinitions}
+
+public class TestFunctions
+{{
+{methods}
+}}";
+ }
+
+ private static string GenerateSourceWithSchemaProperties(List properties)
+ {
+ var props = string.Join("\n", properties.Select(prop =>
+ $@" [OpenApiSchema(Description = ""The {prop}"")]
+ public string {char.ToUpper(prop[0])}{prop.Substring(1)} {{ get; set; }}"));
+
+ return $@"
+using Amazon.Lambda.Annotations;
+using Amazon.Lambda.Annotations.APIGateway;
+using Oproto.Lambda.OpenApi.Attributes;
+
+[assembly: OpenApiInfo(""Test API"", ""1.0.0"")]
+
+public class TestModel
+{{
+{props}
+}}
+
+public class TestFunctions
+{{
+ [LambdaFunction]
+ [HttpApi(LambdaHttpMethod.Post, ""/items"")]
+ public TestModel CreateItem([FromBody] TestModel model) => model;
+}}";
+ }
+
+ private static string GenerateSourceWithTagGroups(List groupNames, List tagNames)
+ {
+ var tagGroupAttributes = string.Join("\n", groupNames.Select((name, index) =>
+ {
+ var tagsForGroup = tagNames.Skip(index % tagNames.Count).Take(2).ToList();
+ var tagsParam = string.Join(", ", tagsForGroup.Select(t => $@"""{t}"""));
+ return $@"[assembly: OpenApiTagGroup(""{name}"", {tagsParam})]";
+ }));
+
+ var tagDefinitions = string.Join("\n", tagNames.Select(tag =>
+ $@"[assembly: OpenApiTagDefinition(""{tag}"", Description = ""Description for {tag}"")]"));
+
+ return $@"
+using Amazon.Lambda.Annotations;
+using Amazon.Lambda.Annotations.APIGateway;
+using Oproto.Lambda.OpenApi.Attributes;
+
+[assembly: OpenApiInfo(""Test API"", ""1.0.0"")]
+{tagDefinitions}
+{tagGroupAttributes}
+
+public class TestFunctions
+{{
+ [LambdaFunction]
+ [HttpApi(LambdaHttpMethod.Get, ""/items"")]
+ [OpenApiTag(""{tagNames.First()}"")]
+ public string GetItems() => ""test"";
+}}";
+ }
+ }
+
+ ///
+ /// **Feature: deterministic-output, Property: Generator Deterministic Output**
+ /// Test that generating the same source twice produces identical output.
+ /// **Validates: Requirements 1.1, 2.1, 3.1, 4.1, 5.1, 7.1, 8.1, 9.1, 11.1**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Generator_ProducesIdenticalOutput_ForSameSource()
+ {
+ return Prop.ForAll(
+ GeneratorTestData.SourceWithMultiplePathsGen().ToArbitrary(),
+ source =>
+ {
+ var json1 = GenerateAndExtractJson(source);
+ var json2 = GenerateAndExtractJson(source);
+
+ if (string.IsNullOrEmpty(json1) || string.IsNullOrEmpty(json2))
+ return false.Label("Failed to generate JSON");
+
+ return (json1 == json2)
+ .Label($"Generated JSON should be identical across runs");
+ });
+ }
+
+ ///
+ /// **Feature: deterministic-output, Property: Path Ordering in Generator**
+ /// For any source with multiple paths, the generated output SHALL have paths in alphabetical order.
+ /// **Validates: Requirements 1.1**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Generator_SortsPaths_Alphabetically()
+ {
+ return Prop.ForAll(
+ GeneratorTestData.SourceWithMultiplePathsGen().ToArbitrary(),
+ source =>
+ {
+ var json = GenerateAndExtractJson(source);
+ if (string.IsNullOrEmpty(json))
+ return false.Label("Failed to generate JSON");
+
+ var paths = ExtractPaths(json);
+ if (paths.Count < 2)
+ return false.Label("Not enough paths generated");
+
+ var isSorted = paths.SequenceEqual(paths.OrderBy(p => p, StringComparer.Ordinal));
+ return isSorted.Label("Paths should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// **Feature: deterministic-output, Property: Tag Ordering in Generator**
+ /// For any source with multiple tags, the generated output SHALL have tags in alphabetical order.
+ /// **Validates: Requirements 4.1**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Generator_SortsTags_Alphabetically()
+ {
+ return Prop.ForAll(
+ GeneratorTestData.SourceWithMultipleTagsGen().ToArbitrary(),
+ source =>
+ {
+ var json = GenerateAndExtractJson(source);
+ if (string.IsNullOrEmpty(json))
+ return false.Label("Failed to generate JSON");
+
+ var tags = ExtractTags(json);
+ if (tags.Count < 2)
+ return false.Label("Not enough tags generated");
+
+ var isSorted = tags.SequenceEqual(tags.OrderBy(t => t, StringComparer.Ordinal));
+ return isSorted.Label("Tags should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// **Feature: deterministic-output, Property: Schema Property Ordering in Generator**
+ /// For any source with schemas containing multiple properties, the generated output SHALL have
+ /// properties in alphabetical order.
+ /// **Validates: Requirements 3.1**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Generator_SortsSchemaProperties_Alphabetically()
+ {
+ return Prop.ForAll(
+ GeneratorTestData.SourceWithSchemaPropertiesGen().ToArbitrary(),
+ source =>
+ {
+ var json = GenerateAndExtractJson(source);
+ if (string.IsNullOrEmpty(json))
+ return false.Label("Failed to generate JSON");
+
+ var properties = ExtractSchemaProperties(json, "TestModel");
+ if (properties.Count < 2)
+ return true.Label("Not enough properties to verify ordering (may be expected)");
+
+ var isSorted = properties.SequenceEqual(properties.OrderBy(p => p, StringComparer.Ordinal));
+ return isSorted.Label("Schema properties should be in alphabetical order");
+ });
+ }
+
+ ///
+ /// **Feature: deterministic-output, Property: Tag Group Ordering in Generator**
+ /// For any source with tag groups, the generated output SHALL have tag groups in alphabetical order.
+ /// **Validates: Requirements 5.1**
+ ///
+ [Property(MaxTest = 100)]
+ public Property Generator_SortsTagGroups_Alphabetically()
+ {
+ return Prop.ForAll(
+ GeneratorTestData.SourceWithTagGroupsGen().ToArbitrary(),
+ source =>
+ {
+ var json = GenerateAndExtractJson(source);
+ if (string.IsNullOrEmpty(json))
+ return false.Label("Failed to generate JSON");
+
+ var tagGroups = ExtractTagGroups(json);
+ if (tagGroups.Count < 2)
+ return false.Label("Not enough tag groups generated");
+
+ var groupNames = tagGroups.Select(g => g.Name).ToList();
+ var isSorted = groupNames.SequenceEqual(groupNames.OrderBy(g => g, StringComparer.Ordinal));
+
+ // Also verify tags within groups are sorted
+ var tagsWithinGroupsSorted = tagGroups.All(g =>
+ g.Tags.SequenceEqual(g.Tags.OrderBy(t => t, StringComparer.Ordinal)));
+
+ return isSorted.Label("Tag groups should be in alphabetical order")
+ .And(tagsWithinGroupsSorted).Label("Tags within groups should be in alphabetical order");
+ });
+ }
+
+ private string GenerateAndExtractJson(string source)
+ {
+ try
+ {
+ var compilation = CompilerHelper.CreateCompilation(source);
+ var generator = new OpenApiSpecGenerator();
+
+ var driver = CSharpGeneratorDriver.Create(generator);
+ driver.RunGeneratorsAndUpdateCompilation(compilation,
+ out var outputCompilation,
+ out var diagnostics);
+
+ if (diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error))
+ return string.Empty;
+
+ return ExtractOpenApiJson(outputCompilation);
+ }
+ catch
+ {
+ return string.Empty;
+ }
+ }
+
+ private string ExtractOpenApiJson(Compilation outputCompilation)
+ {
+ var generatedFile = outputCompilation.SyntaxTrees
+ .FirstOrDefault(x => x.FilePath.EndsWith("OpenApiOutput.g.cs"));
+
+ if (generatedFile == null)
+ return string.Empty;
+
+ var generatedContent = generatedFile.GetRoot().GetText().ToString();
+
+ var attributeStart = generatedContent.IndexOf("[assembly: OpenApiOutput(@\"") + 26;
+ var attributeEnd = generatedContent.LastIndexOf("\", \"openapi.json\")]");
+
+ if (attributeStart < 26 || attributeEnd < 0)
+ return string.Empty;
+
+ var rawJson = generatedContent[attributeStart..attributeEnd];
+
+ return rawJson
+ .Replace("\"\"", "\"")
+ .Replace("\r\n", " ")
+ .Replace("\n", " ")
+ .Replace("\r", " ")
+ .Replace("\\\"", "\"")
+ .Trim()
+ .TrimStart('"')
+ .TrimEnd('"');
+ }
+
+ private List ExtractPaths(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("paths", out var pathsElement))
+ {
+ return pathsElement.EnumerateObject()
+ .Select(p => p.Name)
+ .ToList();
+ }
+ }
+ catch { }
+ return new List();
+ }
+
+ private List ExtractTags(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("tags", out var tagsElement))
+ {
+ return tagsElement.EnumerateArray()
+ .Select(t => t.GetProperty("name").GetString())
+ .Where(n => n != null)
+ .ToList()!;
+ }
+ }
+ catch { }
+ return new List();
+ }
+
+ private List ExtractSchemaProperties(string json, string schemaName)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("components", out var components) &&
+ components.TryGetProperty("schemas", out var schemas) &&
+ schemas.TryGetProperty(schemaName, out var schema) &&
+ schema.TryGetProperty("properties", out var properties))
+ {
+ return properties.EnumerateObject()
+ .Select(p => p.Name)
+ .ToList();
+ }
+ }
+ catch { }
+ return new List();
+ }
+
+ private List<(string Name, List Tags)> ExtractTagGroups(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("x-tagGroups", out var tagGroupsElement))
+ {
+ return tagGroupsElement.EnumerateArray()
+ .Select(g => (
+ Name: g.GetProperty("name").GetString() ?? "",
+ Tags: g.GetProperty("tags").EnumerateArray()
+ .Select(t => t.GetString() ?? "")
+ .ToList()
+ ))
+ .ToList();
+ }
+ }
+ catch { }
+ return new List<(string, List)>();
+ }
+}
diff --git a/Oproto.Lambda.OpenApi.Tests/TagGroupGenerationPropertyTests.cs b/Oproto.Lambda.OpenApi.Tests/TagGroupGenerationPropertyTests.cs
index 8a24a58..b51ab55 100644
--- a/Oproto.Lambda.OpenApi.Tests/TagGroupGenerationPropertyTests.cs
+++ b/Oproto.Lambda.OpenApi.Tests/TagGroupGenerationPropertyTests.cs
@@ -48,14 +48,15 @@ public Property TagGroupAttribute_ParsesNameAndTags()
}
///
- /// **Feature: tag-groups-extension, Property 2: Tag group order preservation**
- /// **Validates: Requirements 1.3, 2.4**
+ /// **Feature: tag-groups-extension, Property 2: Tag group deterministic ordering**
+ /// **Validates: Requirements 5.1, 5.2 (deterministic-output)**
///
/// For any sequence of OpenApiTagGroupAttribute attributes applied to an assembly,
- /// the Source_Generator SHALL output the tag groups in the same order as they are defined.
+ /// the Source_Generator SHALL output the tag groups in alphabetical order by group name
+ /// for deterministic output.
///
[Property(MaxTest = 100)]
- public Property TagGroupAttributes_PreserveOrder()
+ public Property TagGroupAttributes_AlphabeticalOrder()
{
// Generate 2-4 distinct group names
var groupNamesGen = Gen.ListOf(Gen.Elements("Group A", "Group B", "Group C", "Group D", "Group E"))
@@ -72,19 +73,20 @@ public Property TagGroupAttributes_PreserveOrder()
if (extractedGroups.Count != groupNames.Count)
return false.Label($"Expected {groupNames.Count} groups, but got {extractedGroups.Count}");
- // Check order is preserved
- var orderPreserved = true;
- for (int i = 0; i < groupNames.Count; i++)
+ // Check groups are in alphabetical order (deterministic output requirement)
+ var expectedOrder = groupNames.OrderBy(n => n, StringComparer.Ordinal).ToList();
+ var orderCorrect = true;
+ for (int i = 0; i < expectedOrder.Count; i++)
{
- if (extractedGroups[i].Name != groupNames[i])
+ if (extractedGroups[i].Name != expectedOrder[i])
{
- orderPreserved = false;
+ orderCorrect = false;
break;
}
}
- return orderPreserved
- .Label($"Expected order [{string.Join(", ", groupNames)}], " +
+ return orderCorrect
+ .Label($"Expected alphabetical order [{string.Join(", ", expectedOrder)}], " +
$"but got [{string.Join(", ", extractedGroups.Select(g => g.Name))}]");
});
}